Compare commits
46 Commits
appwrite-v
...
98601b4b3d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98601b4b3d | ||
|
|
3cc6ab3343 | ||
|
|
d7f9cf75a3 | ||
|
|
5361800ca9 | ||
|
|
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"
|
||||||
|
|||||||
87
src/App.jsx
87
src/App.jsx
@@ -1,24 +1,30 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||||
import Navbar from './components/Navbar'
|
import Sidebar from './components/Navbar'
|
||||||
import Footer from './components/Footer'
|
import Footer from './components/Footer'
|
||||||
import PixelBlast from './components/PixelBlast'
|
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import TicketsPage from './pages/TicketsPage'
|
import TicketsPage from './pages/TicketsPage'
|
||||||
import DashboardPage from './pages/DashboardPage'
|
import DashboardPage from './pages/DashboardPage'
|
||||||
import AssetsPage from './pages/AssetsPage'
|
import AssetsPage from './pages/AssetsPage'
|
||||||
import PlanboardPage from './pages/PlanboardPage'
|
import PlanboardPage from './pages/PlanboardPage'
|
||||||
import ProjectsPage from './pages/ProjectsPage'
|
import ProjectsPage from './pages/ProjectsPage'
|
||||||
|
import RoadmapsPage from './pages/RoadmapsPage'
|
||||||
|
import RoadmapDetailPage from './pages/RoadmapDetailPage'
|
||||||
import ReportsPage from './pages/ReportsPage'
|
import ReportsPage from './pages/ReportsPage'
|
||||||
import DocsPage from './pages/DocsPage'
|
import DocsPage from './pages/DocsPage'
|
||||||
import AdminPage from './pages/AdminPage'
|
import AdminPage from './pages/AdminPage'
|
||||||
|
import CustomersPage from './pages/CustomersPage'
|
||||||
|
import LeadsPage from './pages/LeadsPage'
|
||||||
|
import CustomerDetailPage from './pages/CustomerDetailPage'
|
||||||
|
import FinancePage from './pages/FinancePage'
|
||||||
|
import DocumentsPage from './pages/DocumentsPage'
|
||||||
|
|
||||||
function ProtectedRoute({ children }) {
|
function ProtectedRoute({ children }) {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center p-4">
|
<div className="text-center p-4" style={{ marginTop: 80 }}>
|
||||||
<div className="spinner"></div>
|
<div className="spinner"></div>
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,10 +46,17 @@ function AppRoutes() {
|
|||||||
<Route path="/login" element={user ? <Navigate to="/tickets" replace /> : <LoginPage />} />
|
<Route path="/login" element={user ? <Navigate to="/tickets" replace /> : <LoginPage />} />
|
||||||
<Route path="/" element={<Navigate to="/tickets" replace />} />
|
<Route path="/" element={<Navigate to="/tickets" replace />} />
|
||||||
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
|
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/leads" element={<ProtectedRoute><LeadsPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/finance" element={<ProtectedRoute><FinancePage /></ProtectedRoute>} />
|
||||||
|
<Route path="/documents" element={<ProtectedRoute><DocumentsPage /></ProtectedRoute>} />
|
||||||
<Route path="/dashboard" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
|
<Route path="/dashboard" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
|
||||||
<Route path="/assets" element={<ProtectedRoute><AssetsPage /></ProtectedRoute>} />
|
<Route path="/assets" element={<ProtectedRoute><AssetsPage /></ProtectedRoute>} />
|
||||||
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
|
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
|
||||||
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></ProtectedRoute>} />
|
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/roadmaps" element={<ProtectedRoute><RoadmapsPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/roadmaps/:id" element={<ProtectedRoute><RoadmapDetailPage /></ProtectedRoute>} />
|
||||||
<Route path="/reports" element={<ProtectedRoute><ReportsPage /></ProtectedRoute>} />
|
<Route path="/reports" element={<ProtectedRoute><ReportsPage /></ProtectedRoute>} />
|
||||||
<Route path="/docs" element={<ProtectedRoute><DocsPage /></ProtectedRoute>} />
|
<Route path="/docs" element={<ProtectedRoute><DocsPage /></ProtectedRoute>} />
|
||||||
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
|
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
|
||||||
@@ -55,71 +68,19 @@ function AppContent() {
|
|||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return <AppRoutes />
|
||||||
<>
|
|
||||||
{/* PixelBlast Background für Login-Seite */}
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100vw',
|
|
||||||
height: '100vh',
|
|
||||||
zIndex: 0,
|
|
||||||
background: '#1a202c'
|
|
||||||
}}>
|
|
||||||
<PixelBlast
|
|
||||||
color="#10b981"
|
|
||||||
variant="circle"
|
|
||||||
pixelSize={4}
|
|
||||||
patternScale={2}
|
|
||||||
patternDensity={0.8}
|
|
||||||
enableRipples={true}
|
|
||||||
rippleIntensityScale={1.5}
|
|
||||||
speed={0.3}
|
|
||||||
edgeFade={0.3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
|
||||||
<AppRoutes />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="app-shell">
|
||||||
{/* PixelBlast Background für Haupt-App */}
|
<Sidebar />
|
||||||
<div style={{
|
<div className="app-main">
|
||||||
position: 'fixed',
|
<div className="app-scroll">
|
||||||
top: 0,
|
<AppRoutes />
|
||||||
left: 0,
|
|
||||||
width: '100vw',
|
|
||||||
height: '100vh',
|
|
||||||
zIndex: 0,
|
|
||||||
background: '#1a202c'
|
|
||||||
}}>
|
|
||||||
<PixelBlast
|
|
||||||
color="#10b981"
|
|
||||||
variant="circle"
|
|
||||||
pixelSize={4}
|
|
||||||
patternScale={2}
|
|
||||||
patternDensity={0.8}
|
|
||||||
enableRipples={true}
|
|
||||||
rippleIntensityScale={1.5}
|
|
||||||
speed={0.3}
|
|
||||||
edgeFade={0.3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
|
||||||
<Navbar />
|
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
||||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
|
||||||
<AppRoutes />
|
|
||||||
</div>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
</div>
|
||||||
|
<Footer />
|
||||||
</div>
|
</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',
|
|
||||||
height: '60px',
|
|
||||||
display: 'flex',
|
|
||||||
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">
|
{error && (
|
||||||
<form onSubmit={handleSubmit}>
|
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||||||
<div className="container">
|
{error}
|
||||||
<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 && (
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="container">
|
<div className="row">
|
||||||
<div className="row">
|
<div className="col col-6">
|
||||||
<div className="col-1"> </div>
|
<div className="form-group">
|
||||||
<div className="col-10">
|
<label className="form-label">Service Type</label>
|
||||||
<div className="alert p-4 rounded-3 border-0" style={{
|
|
||||||
background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
|
|
||||||
color: 'white',
|
|
||||||
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
|
<select
|
||||||
className="form-select bg-dark text-white"
|
className="form-control"
|
||||||
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">
|
||||||
|
<label className="form-label">{isAcquisition ? 'Akquise-Ergebnis' : 'New Status'}</label>
|
||||||
<select
|
<select
|
||||||
className="form-select bg-dark text-white"
|
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">
|
||||||
|
<label className="form-label">New Response Level</label>
|
||||||
<select
|
<select
|
||||||
className="form-select bg-dark text-white"
|
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">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="form-check-input"
|
|
||||||
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">
|
||||||
|
<label className="form-label">Start Date</label>
|
||||||
<input
|
<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">
|
||||||
|
<label className="form-label">End Date</label>
|
||||||
<input
|
<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">
|
||||||
|
<label className="form-label">Start Time</label>
|
||||||
<input
|
<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">
|
||||||
|
<label className="form-label">End Time</label>
|
||||||
<input
|
<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,6 +35,7 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
|
|||||||
}
|
}
|
||||||
|
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
|
setStatus('Lade in Ablage hoch...')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await storage.createFile(
|
const response = await storage.createFile(
|
||||||
@@ -41,6 +44,30 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
|
|||||||
file
|
file
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Automatisch nach Paperless-ngx schieben (wichtige Kundendokumente)
|
||||||
|
const woid = ticket?.woid || workorderId
|
||||||
|
const correspondent = ticket?.customerName || undefined
|
||||||
|
if (correspondent || woid) {
|
||||||
|
setStatus('Synchronisiere mit Paperless...')
|
||||||
|
const tags = []
|
||||||
|
if (correspondent) tags.push(correspondent)
|
||||||
|
if (woid) tags.push(`WOID-${woid}`)
|
||||||
|
tags.push('Ticket')
|
||||||
|
try {
|
||||||
|
await integrationsApi.uploadToPaperless({
|
||||||
|
bucketId: BUCKET_ID,
|
||||||
|
fileId: response.$id,
|
||||||
|
filename: file.name,
|
||||||
|
title: `${woid ? `[${woid}] ` : ''}${file.name}`,
|
||||||
|
correspondent,
|
||||||
|
tags,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Paperless-Sync fehlgeschlagen:', e.message)
|
||||||
|
// Upload in Appwrite ist trotzdem erfolgt - nicht hart abbrechen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onUploadComplete?.(response)
|
onUploadComplete?.(response)
|
||||||
onClose()
|
onClose()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -48,6 +75,7 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
|
|||||||
alert('Error uploading file: ' + error.message)
|
alert('Error uploading file: ' + error.message)
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
|
setStatus('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +100,7 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
|
|||||||
{uploading ? (
|
{uploading ? (
|
||||||
<div>
|
<div>
|
||||||
<FaSpinner className="spinner" size={32} />
|
<FaSpinner className="spinner" size={32} />
|
||||||
<p className="mt-1">Uploading file...</p>
|
<p className="mt-1">{status || 'Uploading file...'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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,128 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { motion } from 'motion/react'
|
|
||||||
import {
|
import {
|
||||||
IconTicket,
|
IconTicket,
|
||||||
IconDeviceDesktop,
|
IconUsers,
|
||||||
IconChartBar,
|
IconTargetArrow,
|
||||||
IconCalendar,
|
IconReportMoney,
|
||||||
|
IconFiles,
|
||||||
IconFolders,
|
IconFolders,
|
||||||
IconFileText,
|
IconCalendar,
|
||||||
IconBook,
|
IconRoute,
|
||||||
|
IconLayoutDashboard,
|
||||||
|
IconReport,
|
||||||
|
IconDeviceDesktop,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconLogout
|
IconBook,
|
||||||
|
IconLogout,
|
||||||
|
IconLayoutSidebarLeftCollapse,
|
||||||
|
IconLayoutSidebarLeftExpand,
|
||||||
} from '@tabler/icons-react'
|
} from '@tabler/icons-react'
|
||||||
import { Sidebar, SidebarBody, SidebarLink } from './ModernSidebar'
|
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Sidebar() {
|
||||||
const { user, logout, isAdmin } = useAuth()
|
const { user, logout, isAdmin } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [open, setOpen] = useState(false)
|
const location = useLocation()
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout()
|
await logout()
|
||||||
navigate('/login')
|
navigate('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
const links = [
|
const groups = [
|
||||||
{
|
{
|
||||||
label: 'Tickets',
|
label: 'Betrieb',
|
||||||
href: '/tickets',
|
links: [
|
||||||
icon: <IconTicket className="icon" />,
|
{ label: 'Tickets', href: '/tickets', icon: <IconTicket /> },
|
||||||
|
{ label: 'Planboard', href: '/planboard', icon: <IconCalendar /> },
|
||||||
|
{ label: 'Roadmaps', href: '/roadmaps', icon: <IconRoute /> },
|
||||||
|
{ label: 'Projekte', href: '/projects', icon: <IconFolders /> },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Assets',
|
label: 'CRM',
|
||||||
href: '/assets',
|
links: [
|
||||||
icon: <IconDeviceDesktop className="icon" />,
|
{ label: 'Kunden', href: '/customers', icon: <IconUsers /> },
|
||||||
|
{ label: 'Leads', href: '/leads', icon: <IconTargetArrow /> },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Dashboard',
|
label: 'Finanzen',
|
||||||
href: '/dashboard',
|
links: [{ label: 'Finanzen', href: '/finance', icon: <IconReportMoney /> }],
|
||||||
icon: <IconChartBar className="icon" />,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Planboard',
|
label: 'Dokumente',
|
||||||
href: '/planboard',
|
links: [{ label: 'Dokumente', href: '/documents', icon: <IconFiles /> }],
|
||||||
icon: <IconCalendar className="icon" />,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Projects',
|
label: 'Auswertung',
|
||||||
href: '/projects',
|
links: [
|
||||||
icon: <IconFolders className="icon" />,
|
{ label: 'Dashboard', href: '/dashboard', icon: <IconLayoutDashboard /> },
|
||||||
|
{ label: 'Reports', href: '/reports', icon: <IconReport /> },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Reports',
|
label: 'System',
|
||||||
href: '/reports',
|
links: [
|
||||||
icon: <IconFileText className="icon" />,
|
{ label: 'Assets', href: '/assets', icon: <IconDeviceDesktop /> },
|
||||||
},
|
...(isAdmin ? [{ label: 'Admin', href: '/admin', icon: <IconSettings /> }] : []),
|
||||||
{
|
{ label: 'Docs', href: '/docs', icon: <IconBook /> },
|
||||||
label: 'Docs',
|
],
|
||||||
href: '/docs',
|
|
||||||
icon: <IconBook className="icon" />,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// Admin link nur wenn isAdmin
|
const isActive = (href) =>
|
||||||
if (isAdmin) {
|
location.pathname === href || location.pathname.startsWith(href + '/')
|
||||||
links.push({
|
|
||||||
label: 'Admin',
|
|
||||||
href: '/admin',
|
|
||||||
icon: <IconSettings className="icon" />,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar open={open} setOpen={setOpen}>
|
<aside className={`side ${collapsed ? 'collapsed' : ''}`}>
|
||||||
<SidebarBody className="justify-between gap-10">
|
<div className="side-head">
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflowX: 'hidden', overflowY: 'auto' }}>
|
<Link to="/" className="side-logo" title="Webklar">W</Link>
|
||||||
<Logo />
|
<div className="side-brand">
|
||||||
<div style={{ marginTop: '2rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
Webklar
|
||||||
{links.map((link, idx) => (
|
<small>Ticket-Plattform</small>
|
||||||
<SidebarLink key={idx} link={link} />
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="side-nav">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div className="side-group" key={group.label}>
|
||||||
|
<div className="side-group-label">{group.label}</div>
|
||||||
|
{group.links.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
to={link.href}
|
||||||
|
className={`side-link ${isActive(link.href) ? 'active' : ''}`}
|
||||||
|
title={link.label}
|
||||||
|
>
|
||||||
|
{link.icon}
|
||||||
|
<span className="side-link-label">{link.label}</span>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
<div>
|
</nav>
|
||||||
{user ? (
|
|
||||||
<>
|
|
||||||
<SidebarLink
|
|
||||||
link={{
|
|
||||||
label: user.name || user.email,
|
|
||||||
href: '#',
|
|
||||||
icon: (
|
|
||||||
<div style={{
|
|
||||||
height: '28px',
|
|
||||||
width: '28px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
color: 'white',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
flexShrink: 0
|
|
||||||
}}>
|
|
||||||
{(user.name || user.email).charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div onClick={handleLogout} style={{ cursor: 'pointer', marginTop: '0.5rem' }}>
|
|
||||||
<SidebarLink
|
|
||||||
link={{
|
|
||||||
label: 'Logout',
|
|
||||||
href: '#',
|
|
||||||
icon: <IconLogout className="icon" />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<SidebarLink
|
|
||||||
link={{
|
|
||||||
label: 'Login',
|
|
||||||
href: '/login',
|
|
||||||
icon: <IconLogout className="icon" />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SidebarBody>
|
|
||||||
</Sidebar>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const Logo = () => {
|
<div className="side-foot">
|
||||||
return (
|
{user && (
|
||||||
<Link
|
<div className="side-link" title={user.name || user.email} style={{ cursor: 'default' }}>
|
||||||
to="/"
|
<div className="side-logo" style={{ width: 28, height: 28, fontSize: 12, background: 'linear-gradient(135deg,#667eea,#764ba2)' }}>
|
||||||
style={{
|
{(user.name || user.email || '?').charAt(0).toUpperCase()}
|
||||||
position: 'relative',
|
</div>
|
||||||
zIndex: 20,
|
<span className="side-link-label text-ellipsis">{user.name || user.email}</span>
|
||||||
display: 'flex',
|
</div>
|
||||||
alignItems: 'center',
|
)}
|
||||||
gap: '0.5rem',
|
<button className="side-toggle" onClick={handleLogout} title="Logout">
|
||||||
padding: '0.25rem 0',
|
<IconLogout size={20} />
|
||||||
fontSize: '0.875rem',
|
<span className="side-link-label">Logout</span>
|
||||||
fontWeight: 'normal',
|
</button>
|
||||||
color: 'var(--text-color, #333)',
|
<button className="side-toggle" onClick={() => setCollapsed((c) => !c)} title="Menue ein-/ausklappen">
|
||||||
textDecoration: 'none'
|
{collapsed ? <IconLayoutSidebarLeftExpand size={20} /> : <IconLayoutSidebarLeftCollapse size={20} />}
|
||||||
}}
|
<span className="side-link-label">Einklappen</span>
|
||||||
>
|
</button>
|
||||||
<div style={{
|
</div>
|
||||||
height: '20px',
|
</aside>
|
||||||
width: '24px',
|
|
||||||
flexShrink: 0,
|
|
||||||
borderRadius: '4px 2px 4px 2px',
|
|
||||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
|
||||||
}} />
|
|
||||||
<motion.span
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
style={{
|
|
||||||
fontWeight: 500,
|
|
||||||
whiteSpace: 'pre',
|
|
||||||
color: 'var(--text-color, #333)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Webklar
|
|
||||||
</motion.span>
|
|
||||||
</Link>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
src/components/ProjectContextPanel.jsx
Normal file
97
src/components/ProjectContextPanel.jsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { IconFolders } from '@tabler/icons-react'
|
||||||
|
import { StatusPill } from './ui/StatusPill'
|
||||||
|
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||||
|
import { fetchRoadmapByTicketId } from '../lib/roadmaps'
|
||||||
|
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projekt-Kontextbox im Ticket: zeigt automatisch das zugehoerige Projekt,
|
||||||
|
* alle anderen Tickets desselben Kunden (klickbar) und den Link zum aktiven Plan.
|
||||||
|
* Rendert nichts, wenn das Ticket keinen Projektbezug hat.
|
||||||
|
*/
|
||||||
|
export default function ProjectContextPanel({ ticket }) {
|
||||||
|
const { fetchByTicketId } = useWebsiteProjects()
|
||||||
|
const [context, setContext] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
// Projektbezug: Roadmap dieses Tickets oder direkt zugewiesenes Projekt
|
||||||
|
const [roadmap, assignedProjects] = await Promise.all([
|
||||||
|
fetchRoadmapByTicketId(ticket.$id).catch(() => null),
|
||||||
|
fetchByTicketId(ticket.$id),
|
||||||
|
])
|
||||||
|
const projectName = roadmap?.projectName || assignedProjects[0]?.projectName || ''
|
||||||
|
if (!roadmap && !assignedProjects.length) {
|
||||||
|
if (!cancelled) setContext(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let otherTickets = []
|
||||||
|
if (ticket.customerId) {
|
||||||
|
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
|
||||||
|
Query.equal('customerId', ticket.customerId),
|
||||||
|
Query.orderDesc('$createdAt'),
|
||||||
|
Query.limit(50),
|
||||||
|
])
|
||||||
|
otherTickets = res.documents.filter((t) => t.$id !== ticket.$id)
|
||||||
|
}
|
||||||
|
if (!cancelled) setContext({ roadmap, projectName, otherTickets })
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setContext(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [ticket.$id, ticket.customerId, fetchByTicketId])
|
||||||
|
|
||||||
|
if (!context) return null
|
||||||
|
const { roadmap, projectName, otherTickets } = context
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ui-card pad" style={{ marginTop: 16 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||||
|
<h5 style={{ fontWeight: 700, margin: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<IconFolders size={18} /> Projekt: {projectName || '-'}
|
||||||
|
</h5>
|
||||||
|
{roadmap && (
|
||||||
|
<Link to={`/roadmaps/${roadmap.$id}`} className="ui-btn">
|
||||||
|
Plan: {roadmap.title}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!ticket.customerId ? (
|
||||||
|
<p className="faint" style={{ fontSize: 13, margin: 0 }}>
|
||||||
|
Kein Kunde zugeordnet — andere Tickets koennen nicht automatisch verknuepft werden.
|
||||||
|
</p>
|
||||||
|
) : !otherTickets.length ? (
|
||||||
|
<p className="faint" style={{ fontSize: 13, margin: 0 }}>
|
||||||
|
Keine weiteren Tickets zu diesem Kunden/Projekt.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{otherTickets.map((t) => (
|
||||||
|
<Link
|
||||||
|
key={t.$id}
|
||||||
|
to={`/tickets?woid=${t.woid}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10, fontSize: 13,
|
||||||
|
padding: '6px 8px', borderRadius: 8, border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="muted" style={{ minWidth: 48, fontWeight: 700 }}>{t.woid}</span>
|
||||||
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{t.topic || t.title || '-'}
|
||||||
|
</span>
|
||||||
|
<StatusPill status={t.status} />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
172
src/components/TicketCard.jsx
Normal file
172
src/components/TicketCard.jsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { FaChevronDown, FaPlay, FaStop, FaPlus, FaClockRotateLeft, FaPen } from 'react-icons/fa6'
|
||||||
|
import { formatDistanceToNow, format } from 'date-fns'
|
||||||
|
import { de } from 'date-fns/locale'
|
||||||
|
import StatusDropdown from './StatusDropdown'
|
||||||
|
import PriorityDropdown from './PriorityDropdown'
|
||||||
|
import EditorDropdown from './EditorDropdown'
|
||||||
|
import ResponseDropdown from './ResponseDropdown'
|
||||||
|
import CreateWorksheetModal from './CreateWorksheetModal'
|
||||||
|
import StatusHistoryModal from './StatusHistoryModal'
|
||||||
|
import WorksheetList from './WorksheetList'
|
||||||
|
import WorksheetStats from './WorksheetStats'
|
||||||
|
import TicketAssignedProjectsPanel from './TicketAssignedProjectsPanel'
|
||||||
|
import TicketProjectsModal from './TicketProjectsModal'
|
||||||
|
import TicketRoadmapPanel from './TicketRoadmapPanel'
|
||||||
|
import ProjectContextPanel from './ProjectContextPanel'
|
||||||
|
import InvoicePanel from './InvoicePanel'
|
||||||
|
import DocumentsPanel from './DocumentsPanel'
|
||||||
|
import Tabs from './ui/Tabs'
|
||||||
|
import Badge from './ui/Badge'
|
||||||
|
import { StatusPill, PriorityPill } from './ui/StatusPill'
|
||||||
|
import { useWorksheets } from '../hooks/useWorksheets'
|
||||||
|
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'details', label: 'Details' },
|
||||||
|
{ id: 'worksheets', label: 'Arbeitsblaetter' },
|
||||||
|
{ id: 'invoices', label: 'Rechnungen' },
|
||||||
|
{ id: 'documents', label: 'Dokumente' },
|
||||||
|
{ id: 'project', label: 'Projekt / Preview' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function TicketCard({ ticket, onUpdate }) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState('details')
|
||||||
|
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
|
||||||
|
const [showHistoryModal, setShowHistoryModal] = useState(false)
|
||||||
|
const [showProjectsModal, setShowProjectsModal] = useState(false)
|
||||||
|
const [projectsRefreshKey, setProjectsRefreshKey] = useState(0)
|
||||||
|
|
||||||
|
const { worksheets, loading: worksheetsLoading, createWorksheet, getTotalTime } = useWorksheets(
|
||||||
|
expanded ? ticket.woid : null
|
||||||
|
)
|
||||||
|
|
||||||
|
const createdAt = new Date(ticket.$createdAt || ticket.createdAt)
|
||||||
|
const elapsed = formatDistanceToNow(createdAt, { locale: de, addSuffix: true })
|
||||||
|
|
||||||
|
const stop = (e) => e.stopPropagation()
|
||||||
|
|
||||||
|
const handleCreateWorksheet = async (worksheetData, currentUser) => {
|
||||||
|
const result = await createWorksheet(worksheetData, currentUser)
|
||||||
|
if (result.success && worksheetData.newStatus !== ticket.status) {
|
||||||
|
await onUpdate(ticket.$id, {
|
||||||
|
status: worksheetData.newStatus,
|
||||||
|
responseLevel: worksheetData.newResponseLevel || ticket.responseLevel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Akquise-Automatik: Ergebnis im Worksheet steuert den Kundenstatus
|
||||||
|
if (result.success && ticket.type === 'Akquise' && ticket.customerId) {
|
||||||
|
const outcome = worksheetData.newStatus
|
||||||
|
const next = outcome === 'Zugesagt' ? 'customer' : outcome === 'Abgesagt' ? 'lost' : null
|
||||||
|
if (next) {
|
||||||
|
try {
|
||||||
|
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, ticket.customerId, {
|
||||||
|
customerStatus: next,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
} catch { /* nicht kritisch */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`tcard ${expanded ? 'open' : ''}`}>
|
||||||
|
<div className="tcard-main" onClick={() => setExpanded((e) => !e)}>
|
||||||
|
<div className="tcard-woid">
|
||||||
|
<span className="id">{ticket.woid || ticket.$id?.slice(-5)}</span>
|
||||||
|
<span className="age">{elapsed}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tcard-body">
|
||||||
|
<div className="tcard-row1">
|
||||||
|
<span className="tcard-customer">{ticket.customerName || 'Unbekannt'}</span>
|
||||||
|
{ticket.customerLocation && <span className="tcard-loc"><EFBFBD> {ticket.customerLocation}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="tcard-topic">{ticket.topic || ticket.title || 'Kein Betreff'}</div>
|
||||||
|
<div className="tcard-badges">
|
||||||
|
<StatusPill status={ticket.status} />
|
||||||
|
<PriorityPill priority={ticket.priority} />
|
||||||
|
{ticket.type && <Badge tone="muted">{ticket.type}</Badge>}
|
||||||
|
{ticket.systemType && ticket.systemType !== 'n/a' && <Badge tone="muted">{ticket.systemType}</Badge>}
|
||||||
|
{ticket.serviceType && <Badge tone="info">{ticket.serviceType}</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tcard-side" onClick={stop}>
|
||||||
|
<div style={{ minWidth: 120 }}>
|
||||||
|
<StatusDropdown value={ticket.status} onChange={(v) => onUpdate(ticket.$id, { status: v })} />
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 110 }}>
|
||||||
|
<PriorityDropdown value={ticket.priority} onChange={(v) => onUpdate(ticket.$id, { priority: v })} />
|
||||||
|
</div>
|
||||||
|
<FaChevronDown className={`chevron ${expanded ? 'up' : ''}`} onClick={() => setExpanded((e) => !e)} style={{ cursor: 'pointer' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="tcard-detail">
|
||||||
|
<div className="flex gap-3 wrap" style={{ marginBottom: 16, fontSize: 13 }}>
|
||||||
|
<span className="muted"><FaPlay style={{ color: 'var(--ok)' }} /> {ticket.startDate || format(createdAt, 'dd.MM.yyyy')}</span>
|
||||||
|
<span className="muted"><FaStop style={{ color: 'var(--danger)' }} /> {ticket.deadline || '-'}</span>
|
||||||
|
{ticket.requestedBy && <span className="muted">Angefragt von: {ticket.requestedBy}</span>}
|
||||||
|
<span className="grow" />
|
||||||
|
<div style={{ minWidth: 150 }}><EditorDropdown value={ticket.assignedTo} onChange={(v) => onUpdate(ticket.$id, { assignedTo: v })} /></div>
|
||||||
|
<div style={{ minWidth: 150 }}><ResponseDropdown value={ticket.responseLevel} onChange={(v) => onUpdate(ticket.$id, { responseLevel: v })} /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs tabs={TABS} active={activeTab} onChange={setActiveTab} />
|
||||||
|
|
||||||
|
<div className="ticket-tab-content">
|
||||||
|
{activeTab === 'details' && (
|
||||||
|
<div>
|
||||||
|
<div className="ui-card pad">
|
||||||
|
<h5 style={{ fontWeight: 700, marginBottom: 12 }}>Ticket-Beschreibung</h5>
|
||||||
|
<p style={{ whiteSpace: 'pre-wrap', color: 'var(--text-muted)', lineHeight: 1.7, margin: 0 }}>
|
||||||
|
{ticket.details || 'Keine Details vorhanden.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ProjectContextPanel ticket={ticket} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'worksheets' && (
|
||||||
|
<div>
|
||||||
|
<div className="flex gap-2" style={{ marginBottom: 16 }}>
|
||||||
|
<button className="ui-btn ui-btn-primary grow" onClick={() => setShowCreateWorksheet(true)}>
|
||||||
|
<FaPlus /> Arbeitsblatt hinzufuegen
|
||||||
|
</button>
|
||||||
|
<button className="ui-btn" onClick={() => setShowProjectsModal(true)} title="Projekte"><FaPen /></button>
|
||||||
|
<button className="ui-btn" onClick={() => setShowHistoryModal(true)} title="Verlauf"><FaClockRotateLeft /></button>
|
||||||
|
</div>
|
||||||
|
{worksheets.length > 0 && (
|
||||||
|
<div className="ui-card pad" style={{ marginBottom: 16 }}>
|
||||||
|
<WorksheetStats worksheets={worksheets} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<WorksheetList worksheets={worksheets} totalTime={getTotalTime()} loading={worksheetsLoading} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'invoices' && <InvoicePanel ticket={ticket} />}
|
||||||
|
{activeTab === 'documents' && <DocumentsPanel ticket={ticket} />}
|
||||||
|
{activeTab === 'project' && (
|
||||||
|
<div>
|
||||||
|
<TicketRoadmapPanel ticket={ticket} />
|
||||||
|
<button className="ui-btn ui-btn-primary" style={{ marginBottom: 12 }} onClick={() => setShowProjectsModal(true)}>
|
||||||
|
<FaPen /> Projekte zuweisen / bearbeiten
|
||||||
|
</button>
|
||||||
|
<TicketAssignedProjectsPanel ticket={ticket} refreshKey={projectsRefreshKey} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateWorksheetModal isOpen={showCreateWorksheet} onClose={() => setShowCreateWorksheet(false)} workorder={ticket} onCreate={handleCreateWorksheet} />
|
||||||
|
<StatusHistoryModal isOpen={showHistoryModal} onClose={() => setShowHistoryModal(false)} worksheets={worksheets} ticket={ticket} />
|
||||||
|
<TicketProjectsModal isOpen={showProjectsModal} onClose={() => setShowProjectsModal(false)} ticket={ticket} onUpdated={() => setProjectsRefreshKey((k) => k + 1)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
src/components/TicketRoadmapPanel.jsx
Normal file
94
src/components/TicketRoadmapPanel.jsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { IconRoute, IconAlertTriangle } from '@tabler/icons-react'
|
||||||
|
import RoadmapProgressBar from './roadmap/RoadmapProgressBar'
|
||||||
|
import {
|
||||||
|
fetchRoadmapByTicketId,
|
||||||
|
fetchRoadmapNodes,
|
||||||
|
computeProgress,
|
||||||
|
isNodeOverdue,
|
||||||
|
NODE_KIND,
|
||||||
|
NODE_STATUS,
|
||||||
|
ROADMAP_STATUS,
|
||||||
|
} from '../lib/roadmaps'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roadmap-Tracking im Projektticket: Fortschritt, Mini-Uebersicht und Link zum Board.
|
||||||
|
* Rendert nichts, wenn zum Ticket keine Roadmap existiert.
|
||||||
|
*/
|
||||||
|
export default function TicketRoadmapPanel({ ticket }) {
|
||||||
|
const [roadmap, setRoadmap] = useState(null)
|
||||||
|
const [nodes, setNodes] = useState([])
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const doc = await fetchRoadmapByTicketId(ticket.$id)
|
||||||
|
if (cancelled) return
|
||||||
|
setRoadmap(doc)
|
||||||
|
if (doc) setNodes(await fetchRoadmapNodes(doc.$id))
|
||||||
|
} catch {
|
||||||
|
/* Panel ist optional */
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoaded(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [ticket.$id])
|
||||||
|
|
||||||
|
if (!loaded || !roadmap) return null
|
||||||
|
|
||||||
|
const progress = computeProgress(nodes)
|
||||||
|
const relevant = nodes.filter((n) => n.kind !== NODE_KIND.START)
|
||||||
|
const counts = {
|
||||||
|
open: relevant.filter((n) => (n.status || NODE_STATUS.OPEN) === NODE_STATUS.OPEN).length,
|
||||||
|
inProgress: relevant.filter((n) => n.status === NODE_STATUS.IN_PROGRESS).length,
|
||||||
|
done: relevant.filter((n) => n.status === NODE_STATUS.DONE).length,
|
||||||
|
}
|
||||||
|
const nextDue = relevant
|
||||||
|
.filter((n) => n.deadline && n.status !== NODE_STATUS.DONE)
|
||||||
|
.sort((a, b) => a.deadline.localeCompare(b.deadline))
|
||||||
|
.slice(0, 3)
|
||||||
|
const closedButActive = ticket.status === 'Closed' && roadmap.status === ROADMAP_STATUS.ACTIVE
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ui-card pad" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||||
|
<h5 style={{ fontWeight: 700, margin: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<IconRoute size={18} /> Roadmap: {roadmap.title}
|
||||||
|
</h5>
|
||||||
|
<Link to={`/roadmaps/${roadmap.$id}`} className="ui-btn">Zur Roadmap</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RoadmapProgressBar progress={progress} />
|
||||||
|
|
||||||
|
<div className="muted" style={{ display: 'flex', gap: 14, fontSize: 13, marginTop: 10, flexWrap: 'wrap' }}>
|
||||||
|
<span>Offen: {counts.open}</span>
|
||||||
|
<span>In Arbeit: {counts.inProgress}</span>
|
||||||
|
<span>Fertig: {counts.done}</span>
|
||||||
|
{roadmap.status === ROADMAP_STATUS.COMPLETED && <span style={{ color: '#34d399' }}>Plan abgeschlossen</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nextDue.length > 0 && (
|
||||||
|
<div style={{ marginTop: 10, fontSize: 13 }}>
|
||||||
|
<span className="faint">Naechste Deadlines: </span>
|
||||||
|
{nextDue.map((n, i) => (
|
||||||
|
<span key={n.$id} style={isNodeOverdue(n) ? { color: 'var(--danger, #ef4444)', fontWeight: 700 } : undefined}>
|
||||||
|
{i > 0 && ' · '}{n.title} ({n.deadline.split('-').reverse().join('.')})
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{closedButActive && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 12, fontSize: 13, color: '#fbbf24' }}>
|
||||||
|
<IconAlertTriangle size={16} style={{ flexShrink: 0 }} />
|
||||||
|
Ticket ist geschlossen, aber der Plan ist erst zu {progress.percent}% fertig.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
const [showHistoryModal, setShowHistoryModal] = useState(false)
|
||||||
|
const [showProjectsModal, setShowProjectsModal] = useState(false)
|
||||||
|
const [projectsRefreshKey, setProjectsRefreshKey] = useState(0)
|
||||||
|
|
||||||
// Worksheets für dieses Ticket laden (nur wenn expanded)
|
|
||||||
const {
|
const {
|
||||||
worksheets,
|
worksheets,
|
||||||
loading: worksheetsLoading,
|
loading: worksheetsLoading,
|
||||||
createWorksheet,
|
createWorksheet,
|
||||||
|
updateWorksheet,
|
||||||
getTotalTime
|
getTotalTime
|
||||||
} = useWorksheets(expanded ? ticket.woid : null)
|
} = useWorksheets(expanded ? ticket.woid : null)
|
||||||
|
|
||||||
|
// Startzeit fuer Git-Push-Worksheets nachtragen (Endzeit kam vom Push)
|
||||||
|
const handleSaveWorksheetTime = async (ws, data) => updateWorksheet(ws.$id, data)
|
||||||
|
|
||||||
const createdAt = new Date(ticket.$createdAt || ticket.createdAt)
|
const createdAt = new Date(ticket.$createdAt || ticket.createdAt)
|
||||||
const elapsed = formatDistanceToNow(createdAt, { locale: de })
|
const elapsed = formatDistanceToNow(createdAt, { locale: de })
|
||||||
|
|
||||||
const handleStatusChange = (newStatus) => {
|
const handleStatusChange = (newStatus) => onUpdate(ticket.$id, { status: newStatus })
|
||||||
onUpdate(ticket.$id, { status: newStatus })
|
const handlePriorityChange = (newPriority) => onUpdate(ticket.$id, { priority: newPriority })
|
||||||
}
|
const handleEditorChange = (newEditor) => onUpdate(ticket.$id, { assignedTo: newEditor })
|
||||||
|
const handleResponseChange = (newResponse) => onUpdate(ticket.$id, { responseLevel: newResponse })
|
||||||
const handlePriorityChange = (newPriority) => {
|
|
||||||
onUpdate(ticket.$id, { priority: newPriority })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEditorChange = (newEditor) => {
|
|
||||||
onUpdate(ticket.$id, { assignedTo: newEditor })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResponseChange = (newResponse) => {
|
|
||||||
onUpdate(ticket.$id, { responseLevel: newResponse })
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleLock = () => {
|
const toggleLock = () => {
|
||||||
if (locked) {
|
if (locked) {
|
||||||
@@ -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,16 +134,10 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
|||||||
{ticket.topic}
|
{ticket.topic}
|
||||||
</td>
|
</td>
|
||||||
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
|
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
|
||||||
<StatusDropdown
|
<StatusDropdown value={ticket.status} onChange={handleStatusChange} />
|
||||||
value={ticket.status}
|
|
||||||
onChange={handleStatusChange}
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
|
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
|
||||||
<PriorityDropdown
|
<PriorityDropdown value={ticket.priority} onChange={handlePriorityChange} />
|
||||||
value={ticket.priority}
|
|
||||||
onChange={handlePriorityChange}
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className={`text-center ${ticket.approvalStatus === 'approved' ? 'bg-green' : 'bg-yellow'}`}
|
className={`text-center ${ticket.approvalStatus === 'approved' ? 'bg-green' : 'bg-yellow'}`}
|
||||||
@@ -163,91 +157,118 @@ 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 && (
|
|
||||||
<>
|
|
||||||
<WorksheetStats worksheets={worksheets} />
|
|
||||||
<hr />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Worksheet-Liste */}
|
{activeTab === 'worksheets' && (
|
||||||
<WorksheetList
|
<div>
|
||||||
worksheets={worksheets}
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'stretch', marginBottom: '16px' }}>
|
||||||
totalTime={getTotalTime()}
|
<button
|
||||||
loading={worksheetsLoading}
|
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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</td>
|
||||||
</>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CreateWorksheetModal
|
<CreateWorksheetModal
|
||||||
@@ -256,8 +277,29 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
|||||||
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={{
|
|
||||||
borderLeft: ws.isComment ? '4px solid #10b981' : '4px solid #4a5568',
|
|
||||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
|
||||||
}} 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={{
|
|
||||||
background: ws.isComment
|
|
||||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
|
||||||
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none'
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<strong className="fs-6">WSID {ws.wsid}</strong>
|
|
||||||
{ws.isComment && (
|
|
||||||
<span className="badge ms-2" style={{
|
|
||||||
background: 'rgba(255,255,255,0.3)'
|
|
||||||
}}>
|
|
||||||
<FaComment className="me-1" /> Kommentar
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<small style={{ opacity: 0.9 }}>
|
|
||||||
{formatDateTime(ws.startDate, ws.startTime)}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card-body p-4">
|
return (
|
||||||
{/* Mitarbeiter & Zeit */}
|
<div key={ws.$id} className="timeline-item mb-4" style={{
|
||||||
<div className="row mb-3">
|
animation: `fadeIn 0.5s ease-in-out ${index * 0.1}s backwards`
|
||||||
<div className="col-md-6">
|
}}>
|
||||||
<div className="d-flex align-items-center">
|
<div className="card border-0 shadow-sm overflow-hidden" style={{
|
||||||
<FaUser className="me-2" style={{ color: '#10b981' }} />
|
borderLeft: isGit ? '4px solid #f97316' : ws.isComment ? '4px solid #10b981' : '4px solid #4a5568',
|
||||||
<strong>{ws.employeeName}</strong>
|
borderRadius: '8px',
|
||||||
{ws.employeeShort && (
|
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
||||||
|
}} 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)'
|
||||||
|
}}>
|
||||||
|
{/* Header - Immer sichtbar, klickbar */}
|
||||||
|
<div
|
||||||
|
className="card-header d-flex justify-content-between align-items-center py-3"
|
||||||
|
onClick={() => toggleWorksheet(ws.wsid)}
|
||||||
|
style={{
|
||||||
|
background: isGit
|
||||||
|
? 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)'
|
||||||
|
: ws.isComment
|
||||||
|
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||||
|
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none',
|
||||||
|
transition: 'background 0.2s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = isGit
|
||||||
|
? 'linear-gradient(135deg, #ea580c 0%, #c2410c 100%)'
|
||||||
|
: ws.isComment
|
||||||
|
? 'linear-gradient(135deg, #059669 0%, #047857 100%)'
|
||||||
|
: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = isGit
|
||||||
|
? 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)'
|
||||||
|
: 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={{
|
<span className="badge ms-2" style={{
|
||||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
background: 'rgba(255,255,255,0.3)'
|
||||||
color: 'white'
|
}}>
|
||||||
}}>{ws.employeeShort}</span>
|
<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>
|
||||||
|
|
||||||
{/* Service Type */}
|
{/* Body - Nur wenn expanded */}
|
||||||
<div className="mb-3">
|
{isExpanded && (
|
||||||
<span className="badge px-3 py-2" style={{
|
<div
|
||||||
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
className="card-body p-4"
|
||||||
color: 'white',
|
style={{
|
||||||
fontSize: '0.85rem'
|
animation: 'slideDown 0.3s ease-out'
|
||||||
}}>{ws.serviceType}</span>
|
}}
|
||||||
</div>
|
>
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
{/* Status-Änderung */}
|
{/* Service Type */}
|
||||||
{ws.oldStatus !== ws.newStatus && (
|
<div className="mb-3">
|
||||||
<div className="mb-3 p-3 rounded-3" style={{
|
<span className="badge px-3 py-2" style={{
|
||||||
background: 'linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%)'
|
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
||||||
}}>
|
color: 'white',
|
||||||
<FaExchangeAlt className="me-2" style={{ color: '#10b981' }} />
|
fontSize: '0.85rem'
|
||||||
<span className="text-muted">Status:</span>{' '}
|
}}>{ws.serviceType}</span>
|
||||||
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{ws.oldStatus}</span>
|
</div>
|
||||||
<span className="mx-2" style={{ color: '#10b981' }}>→</span>
|
|
||||||
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{ws.newStatus}</span>
|
{/* Status-Änderung */}
|
||||||
|
{ws.oldStatus !== ws.newStatus && (
|
||||||
|
<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%)'
|
||||||
|
}}>
|
||||||
|
<FaExchangeAlt className="me-2" style={{ color: '#10b981' }} />
|
||||||
|
<span className="text-muted">Status:</span>{' '}
|
||||||
|
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{ws.oldStatus}</span>
|
||||||
|
<span className="mx-2" style={{ color: '#10b981' }}>→</span>
|
||||||
|
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{ws.newStatus}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Response Level-Änderung */}
|
||||||
|
{ws.oldResponseLevel && ws.newResponseLevel && ws.oldResponseLevel !== ws.newResponseLevel && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<span className="text-muted">Response Level:</span>{' '}
|
||||||
|
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{ws.oldResponseLevel}</span>
|
||||||
|
<span className="mx-2">→</span>
|
||||||
|
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{ws.newResponseLevel}</span>
|
||||||
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
166
src/components/roadmap/NodeDetailPanel.jsx
Normal file
166
src/components/roadmap/NodeDetailPanel.jsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { IconTrash, IconX } from '@tabler/icons-react'
|
||||||
|
import { Card, Button, Field } from '../ui'
|
||||||
|
import { useEmployees } from '../../hooks/useEmployees'
|
||||||
|
import { NODE_KIND, NODE_STATUS, NODE_STATUS_LABELS } from '../../lib/roadmaps'
|
||||||
|
|
||||||
|
const STATUS_ORDER = [NODE_STATUS.OPEN, NODE_STATUS.IN_PROGRESS, NODE_STATUS.DONE]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seitliches Detail-Panel fuer den ausgewaehlten Knoten:
|
||||||
|
* Titel, Status, Zustaendiger, Deadline, Notizen, eingehende Verbindungen.
|
||||||
|
*/
|
||||||
|
export default function NodeDetailPanel({
|
||||||
|
node,
|
||||||
|
nodes,
|
||||||
|
readOnly,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
onSetStatus,
|
||||||
|
onDelete,
|
||||||
|
onDisconnect,
|
||||||
|
}) {
|
||||||
|
const { employees } = useEmployees()
|
||||||
|
const [form, setForm] = useState({ title: '', assignedTo: '', deadline: '', notes: '' })
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setForm({
|
||||||
|
title: node.title || '',
|
||||||
|
assignedTo: node.assignedTo || '',
|
||||||
|
deadline: node.deadline || '',
|
||||||
|
notes: node.notes || '',
|
||||||
|
})
|
||||||
|
setError('')
|
||||||
|
}, [node.$id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const isStart = node.kind === NODE_KIND.START
|
||||||
|
const incoming = (node.sourceIds || [])
|
||||||
|
.map((id) => nodes.find((n) => n.$id === id))
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.title.trim()) {
|
||||||
|
setError('Titel darf nicht leer sein.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
const employee = employees.find((e) => e.userId === form.assignedTo)
|
||||||
|
const result = await onSave(node, {
|
||||||
|
title: form.title.trim(),
|
||||||
|
assignedTo: form.assignedTo,
|
||||||
|
assignedName: employee?.displayName || '',
|
||||||
|
deadline: form.deadline,
|
||||||
|
notes: form.notes,
|
||||||
|
})
|
||||||
|
setSaving(false)
|
||||||
|
if (!result.success) setError(result.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={isStart ? 'Start-Knoten' : node.kind === NODE_KIND.GOAL ? 'Ziel-Knoten' : 'Schritt'}
|
||||||
|
actions={<Button variant="ghost" onClick={onClose}><IconX size={16} /></Button>}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<Field label="Titel">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.title}
|
||||||
|
maxLength={128}
|
||||||
|
disabled={readOnly}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{!isStart && (
|
||||||
|
<Field label="Status">
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{STATUS_ORDER.map((status) => (
|
||||||
|
<Button
|
||||||
|
key={status}
|
||||||
|
variant={node.status === status ? 'primary' : 'default'}
|
||||||
|
disabled={readOnly}
|
||||||
|
onClick={() => onSetStatus(node, status)}
|
||||||
|
>
|
||||||
|
{NODE_STATUS_LABELS[status]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isStart && (
|
||||||
|
<Field label="Zustaendig">
|
||||||
|
<select
|
||||||
|
value={form.assignedTo}
|
||||||
|
disabled={readOnly}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, assignedTo: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">Niemand</option>
|
||||||
|
{employees.map((e) => (
|
||||||
|
<option key={e.userId} value={e.userId}>{e.displayName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isStart && (
|
||||||
|
<Field label="Deadline">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.deadline}
|
||||||
|
disabled={readOnly}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, deadline: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Notizen">
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={form.notes}
|
||||||
|
maxLength={2000}
|
||||||
|
disabled={readOnly}
|
||||||
|
placeholder="Details, Anforderungen, Links…"
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{incoming.length > 0 && (
|
||||||
|
<Field label="Eingehende Verbindungen">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{incoming.map((source) => (
|
||||||
|
<div key={source.$id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
|
||||||
|
<span className="muted">{source.title}</span>
|
||||||
|
{!readOnly && (
|
||||||
|
<Button variant="ghost" title="Verbindung loesen" onClick={() => onDisconnect(source.$id, node)}>
|
||||||
|
<IconX size={14} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p style={{ color: 'var(--danger, #ef4444)', fontSize: 13 }}>{error}</p>}
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8 }}>
|
||||||
|
{!isStart ? (
|
||||||
|
<Button variant="ghost" onClick={() => onDelete(node)} title="Knoten loeschen">
|
||||||
|
<IconTrash size={15} /> Loeschen
|
||||||
|
</Button>
|
||||||
|
) : <span />}
|
||||||
|
<Button variant="primary" onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? 'Speichere…' : 'Speichern'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
168
src/components/roadmap/RoadmapBoard.jsx
Normal file
168
src/components/roadmap/RoadmapBoard.jsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
import { IconPlus } from '@tabler/icons-react'
|
||||||
|
import RoadmapNodeCard from './RoadmapNodeCard'
|
||||||
|
import { NODE_KIND } from '../../lib/roadmaps'
|
||||||
|
|
||||||
|
const COLUMN_WIDTH = 230
|
||||||
|
const COLUMN_GAP = 48
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steckbrett: Spalten von links (Start) nach rechts (Ziele), Knoten vertikal
|
||||||
|
* gestapelt, Kanten als SVG-Overlay. Verbinden per Klick-Modus:
|
||||||
|
* Connect-Handle am Quellknoten -> gueltige Ziele (Spalte weiter rechts) anklicken.
|
||||||
|
*/
|
||||||
|
export default function RoadmapBoard({
|
||||||
|
roadmap,
|
||||||
|
nodes,
|
||||||
|
selectedNode,
|
||||||
|
readOnly,
|
||||||
|
onSelectNode,
|
||||||
|
onAddNode,
|
||||||
|
onConnect,
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef(null)
|
||||||
|
const nodeRefs = useRef({})
|
||||||
|
const [edges, setEdges] = useState([])
|
||||||
|
const [connectSource, setConnectSource] = useState(null)
|
||||||
|
|
||||||
|
const columnCount = Math.max(roadmap.columnCount || 3, 2)
|
||||||
|
const columns = Array.from({ length: columnCount }, (_, c) =>
|
||||||
|
nodes.filter((n) => n.column === c).sort((a, b) => (a.row || 0) - (b.row || 0))
|
||||||
|
)
|
||||||
|
|
||||||
|
const setNodeRef = useCallback((id) => (el) => {
|
||||||
|
if (el) nodeRefs.current[id] = el
|
||||||
|
else delete nodeRefs.current[id]
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Kanten-Geometrie nach jedem Layout neu berechnen (Anker relativ zum Board)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
const compute = () => {
|
||||||
|
const base = container.getBoundingClientRect()
|
||||||
|
const next = []
|
||||||
|
for (const node of nodes) {
|
||||||
|
for (const sourceId of node.sourceIds || []) {
|
||||||
|
const sourceEl = nodeRefs.current[sourceId]
|
||||||
|
const targetEl = nodeRefs.current[node.$id]
|
||||||
|
if (!sourceEl || !targetEl) continue
|
||||||
|
const s = sourceEl.getBoundingClientRect()
|
||||||
|
const t = targetEl.getBoundingClientRect()
|
||||||
|
next.push({
|
||||||
|
id: `${sourceId}-${node.$id}`,
|
||||||
|
x1: s.right - base.left,
|
||||||
|
y1: s.top + s.height / 2 - base.top,
|
||||||
|
x2: t.left - base.left,
|
||||||
|
y2: t.top + t.height / 2 - base.top,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setEdges(next)
|
||||||
|
}
|
||||||
|
compute()
|
||||||
|
const observer = new ResizeObserver(compute)
|
||||||
|
observer.observe(container)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [nodes, columnCount])
|
||||||
|
|
||||||
|
const handleStartConnect = (node) => setConnectSource(node)
|
||||||
|
|
||||||
|
const handleSelect = async (node) => {
|
||||||
|
if (connectSource) {
|
||||||
|
if (node.column > connectSource.column) {
|
||||||
|
await onConnect(connectSource, node)
|
||||||
|
}
|
||||||
|
setConnectSource(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onSelectNode(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnLabel = (c) => {
|
||||||
|
if (c === 0) return 'Start'
|
||||||
|
if (c === columnCount - 1) return 'Ziele'
|
||||||
|
return `Phase ${c}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ overflowX: 'auto', paddingBottom: 8 }}>
|
||||||
|
{connectSource && (
|
||||||
|
<div
|
||||||
|
className="muted"
|
||||||
|
style={{ fontSize: 13, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 10 }}
|
||||||
|
>
|
||||||
|
Verbinden von <strong>{connectSource.title}</strong>: Zielknoten rechts davon anklicken.
|
||||||
|
<button type="button" className="ui-btn" onClick={() => setConnectSource(null)}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${columnCount}, ${COLUMN_WIDTH}px)`,
|
||||||
|
gap: `16px ${COLUMN_GAP}px`,
|
||||||
|
alignItems: 'start',
|
||||||
|
minWidth: columnCount * (COLUMN_WIDTH + COLUMN_GAP),
|
||||||
|
padding: '8px 4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0 }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<marker id="roadmap-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
||||||
|
<path d="M0,0 L8,4 L0,8 Z" fill="var(--text-muted, #94a3b8)" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
{edges.map((e) => {
|
||||||
|
const dx = Math.max((e.x2 - e.x1) / 2, 24)
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={e.id}
|
||||||
|
d={`M ${e.x1} ${e.y1} C ${e.x1 + dx} ${e.y1}, ${e.x2 - dx} ${e.y2}, ${e.x2} ${e.y2}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--text-muted, #94a3b8)"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
markerEnd="url(#roadmap-arrow)"
|
||||||
|
opacity="0.7"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{columns.map((columnNodes, c) => (
|
||||||
|
<div key={c} style={{ display: 'flex', flexDirection: 'column', gap: 14, zIndex: 1 }}>
|
||||||
|
<div className="faint" style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
{columnLabel(c)}
|
||||||
|
</div>
|
||||||
|
{columnNodes.map((node) => (
|
||||||
|
<RoadmapNodeCard
|
||||||
|
key={node.$id}
|
||||||
|
node={node}
|
||||||
|
selected={selectedNode?.$id === node.$id}
|
||||||
|
readOnly={readOnly}
|
||||||
|
connectSource={connectSource}
|
||||||
|
isValidTarget={Boolean(connectSource) && node.column > connectSource.column}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onStartConnect={handleStartConnect}
|
||||||
|
nodeRef={setNodeRef(node.$id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!readOnly && c > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn"
|
||||||
|
style={{ borderStyle: 'dashed', fontSize: 12 }}
|
||||||
|
onClick={() => onAddNode(c, c === columnCount - 1 ? NODE_KIND.GOAL : NODE_KIND.STEP, columnNodes.length)}
|
||||||
|
>
|
||||||
|
<IconPlus size={14} /> {c === columnCount - 1 ? 'Ziel' : 'Schritt'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
src/components/roadmap/RoadmapNodeCard.jsx
Normal file
101
src/components/roadmap/RoadmapNodeCard.jsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { IconFlag, IconPlayerPlay, IconLink } from '@tabler/icons-react'
|
||||||
|
import { NODE_KIND, NODE_STATUS, isNodeOverdue } from '../../lib/roadmaps'
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
[NODE_STATUS.OPEN]: 'var(--text-muted, #94a3b8)',
|
||||||
|
[NODE_STATUS.IN_PROGRESS]: '#fbbf24',
|
||||||
|
[NODE_STATUS.DONE]: '#34d399',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Knoten-Kachel im Steckbrett.
|
||||||
|
* connectMode: Board wartet auf Zielauswahl; isValidTarget markiert erlaubte Ziele.
|
||||||
|
*/
|
||||||
|
export default function RoadmapNodeCard({
|
||||||
|
node,
|
||||||
|
selected,
|
||||||
|
readOnly,
|
||||||
|
connectSource,
|
||||||
|
isValidTarget,
|
||||||
|
onSelect,
|
||||||
|
onStartConnect,
|
||||||
|
nodeRef,
|
||||||
|
}) {
|
||||||
|
const overdue = isNodeOverdue(node)
|
||||||
|
const isStart = node.kind === NODE_KIND.START
|
||||||
|
const isGoal = node.kind === NODE_KIND.GOAL
|
||||||
|
const connecting = Boolean(connectSource)
|
||||||
|
const isConnectSource = connectSource?.$id === node.$id
|
||||||
|
|
||||||
|
const borderColor = isConnectSource
|
||||||
|
? 'var(--accent, #3b82f6)'
|
||||||
|
: connecting && isValidTarget
|
||||||
|
? '#34d399'
|
||||||
|
: selected
|
||||||
|
? 'var(--accent, #3b82f6)'
|
||||||
|
: overdue
|
||||||
|
? 'var(--danger, #ef4444)'
|
||||||
|
: 'var(--border)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={nodeRef}
|
||||||
|
onClick={() => onSelect(node)}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
background: isGoal ? 'rgba(59,130,246,0.10)' : 'var(--surface-2, rgba(45,55,72,0.65))',
|
||||||
|
border: `2px solid ${borderColor}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '10px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
opacity: connecting && !isValidTarget && !isConnectSource ? 0.45 : 1,
|
||||||
|
transition: 'border-color 0.15s ease, opacity 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{isStart && <IconPlayerPlay size={15} style={{ flexShrink: 0 }} />}
|
||||||
|
{isGoal && <IconFlag size={15} style={{ flexShrink: 0, color: '#93c5fd' }} />}
|
||||||
|
{!isStart && (
|
||||||
|
<span
|
||||||
|
title={`Status: ${node.status || 'open'}`}
|
||||||
|
style={{
|
||||||
|
width: 10, height: 10, borderRadius: 5, flexShrink: 0,
|
||||||
|
background: STATUS_COLORS[node.status] || STATUS_COLORS.open,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{node.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(node.assignedName || node.deadline) && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 6, fontSize: 11 }} className="faint">
|
||||||
|
{node.assignedName && <span>{node.assignedName}</span>}
|
||||||
|
{node.deadline && (
|
||||||
|
<span style={overdue ? { color: 'var(--danger, #ef4444)', fontWeight: 700 } : undefined}>
|
||||||
|
bis {node.deadline.split('-').reverse().join('.')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!readOnly && !isGoal && !connecting && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Verbindung von hier starten"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onStartConnect(node) }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', right: -11, top: '50%', transform: 'translateY(-50%)',
|
||||||
|
width: 22, height: 22, borderRadius: 11,
|
||||||
|
border: '1px solid var(--border)', background: 'var(--surface, #1e293b)',
|
||||||
|
color: 'var(--text-muted)', display: 'flex', alignItems: 'center',
|
||||||
|
justifyContent: 'center', cursor: 'crosshair', padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconLink size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
src/components/roadmap/RoadmapProgressBar.jsx
Normal file
23
src/components/roadmap/RoadmapProgressBar.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/** Fortschrittsbalken fuer Roadmaps: Prozent + "x von y Knoten fertig". */
|
||||||
|
export default function RoadmapProgressBar({ progress, compact = false }) {
|
||||||
|
const { percent, done, total } = progress || { percent: 0, done: 0, total: 0 }
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: compact ? 120 : 180 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||||
|
<span className="muted">{percent}%</span>
|
||||||
|
{!compact && <span className="faint">{done} von {total} fertig</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 6, borderRadius: 3, background: 'var(--border)', overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${percent}%`,
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 3,
|
||||||
|
background: percent >= 100 ? 'var(--ok, #22c55e)' : 'var(--accent, #3b82f6)',
|
||||||
|
transition: 'width 0.3s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
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))
|
||||||
@@ -60,17 +120,23 @@ export function AuthProvider({ children }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasAppwriteSession()) {
|
||||||
|
setUser(null)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await account.get()
|
const session = await account.get()
|
||||||
setUser(session)
|
setUser(session)
|
||||||
// Automatisch zur employees Collection hinzufügen
|
|
||||||
await ensureEmployeeExists(session)
|
await ensureEmployeeExists(session)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Kein Fehler loggen beim initialen Check - das ist normal wenn nicht eingeloggt
|
|
||||||
// Nur loggen wenn es ein unerwarteter Fehler ist (nicht 401)
|
|
||||||
if (error.code !== 401 && error.code !== 404) {
|
if (error.code !== 401 && error.code !== 404) {
|
||||||
console.error('Unexpected error checking user:', error)
|
console.error('Unexpected error checking user:', error)
|
||||||
}
|
}
|
||||||
|
if (error.code === 401) {
|
||||||
|
clearAllLocalAppwriteSessions()
|
||||||
|
}
|
||||||
setUser(null)
|
setUser(null)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -79,7 +145,6 @@ export function AuthProvider({ children }) {
|
|||||||
|
|
||||||
async function login(email, password) {
|
async function login(email, password) {
|
||||||
if (DEMO_MODE) {
|
if (DEMO_MODE) {
|
||||||
// Demo login - accept any credentials
|
|
||||||
const demoUser = { $id: 'demo', email, name: email.split('@')[0] }
|
const demoUser = { $id: 'demo', email, name: email.split('@')[0] }
|
||||||
localStorage.setItem('demo_user', JSON.stringify(demoUser))
|
localStorage.setItem('demo_user', JSON.stringify(demoUser))
|
||||||
setUser(demoUser)
|
setUser(demoUser)
|
||||||
@@ -87,19 +152,12 @@ export function AuthProvider({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Appwrite 1.5.7 / SDK 13.0 - versuche beide Methoden für Kompatibilität
|
const normalizedEmail = email.trim()
|
||||||
try {
|
const normalizedPassword = password.trim()
|
||||||
await account.createEmailSession(email, password)
|
clearStaleAppwriteSessions()
|
||||||
} catch (e) {
|
|
||||||
// Fallback für ältere API
|
await createSessionWithCleanup(normalizedEmail, normalizedPassword)
|
||||||
if (account.createEmailPasswordSession) {
|
|
||||||
await account.createEmailPasswordSession(email, password)
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// User-Daten laden und automatisch zur employees Collection hinzufügen
|
|
||||||
const session = await account.get()
|
const session = await account.get()
|
||||||
setUser(session)
|
setUser(session)
|
||||||
await ensureEmployeeExists(session)
|
await ensureEmployeeExists(session)
|
||||||
@@ -109,8 +167,16 @@ export function AuthProvider({ children }) {
|
|||||||
console.error('Login error:', error)
|
console.error('Login error:', error)
|
||||||
let errorMessage = error.message || 'Login fehlgeschlagen'
|
let errorMessage = error.message || 'Login fehlgeschlagen'
|
||||||
|
|
||||||
// Bessere Fehlermeldungen
|
if (error.code === 429 || errorMessage.includes('Rate limit')) {
|
||||||
if (error.code === 401 || errorMessage.includes('Invalid credentials')) {
|
errorMessage = 'Zu viele Login-Versuche. Bitte 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.'
|
||||||
@@ -130,55 +196,23 @@ export function AuthProvider({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await account.deleteSession('current')
|
await account.deleteSessions()
|
||||||
setUser(null)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout error:', error)
|
console.error('Logout error:', error)
|
||||||
|
} finally {
|
||||||
|
clearAllLocalAppwriteSessions()
|
||||||
|
setUser(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }
|
||||||
|
}
|
||||||
135
src/hooks/useRoadmapNodes.js
Normal file
135
src/hooks/useRoadmapNodes.js
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { databases, DATABASE_ID, COLLECTIONS, ID, isDemoMode } from '../lib/appwrite'
|
||||||
|
import {
|
||||||
|
fetchRoadmapNodes,
|
||||||
|
logNodeStatusWorksheet,
|
||||||
|
allGoalsDone,
|
||||||
|
NODE_KIND,
|
||||||
|
NODE_STATUS,
|
||||||
|
} from '../lib/roadmaps'
|
||||||
|
|
||||||
|
export function useRoadmapNodes() {
|
||||||
|
const [nodes, setNodes] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const fetchNodes = useCallback(async (roadmapId) => {
|
||||||
|
if (!roadmapId || isDemoMode) return []
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const docs = await fetchRoadmapNodes(roadmapId)
|
||||||
|
setNodes(docs)
|
||||||
|
return docs
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
return []
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const createNode = async (roadmapId, { title, column, row, kind }) => {
|
||||||
|
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const doc = await databases.createDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, ID.unique(), {
|
||||||
|
roadmapId,
|
||||||
|
title: title || 'Neuer Schritt',
|
||||||
|
kind: kind || NODE_KIND.STEP,
|
||||||
|
column,
|
||||||
|
row,
|
||||||
|
status: NODE_STATUS.OPEN,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
setNodes((prev) => [...prev, doc])
|
||||||
|
return { success: true, data: doc }
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateNode = async (nodeId, data) => {
|
||||||
|
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
|
||||||
|
try {
|
||||||
|
const doc = await databases.updateDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, nodeId, {
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
setNodes((prev) => prev.map((n) => (n.$id === nodeId ? doc : n)))
|
||||||
|
return { success: true, data: doc }
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Knoten loeschen inkl. Bereinigung eingehender Kanten bei den Nachfolgern. */
|
||||||
|
const deleteNode = async (node) => {
|
||||||
|
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
|
||||||
|
if (node.kind === NODE_KIND.START) {
|
||||||
|
return { success: false, error: 'Der Start-Knoten kann nicht geloescht werden.' }
|
||||||
|
}
|
||||||
|
if (node.kind === NODE_KIND.GOAL && nodes.filter((n) => n.kind === NODE_KIND.GOAL).length <= 1) {
|
||||||
|
return { success: false, error: 'Ein Plan braucht mindestens ein Ziel.' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const successors = nodes.filter((n) => (n.sourceIds || []).includes(node.$id))
|
||||||
|
await Promise.all(successors.map((n) =>
|
||||||
|
databases.updateDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, n.$id, {
|
||||||
|
sourceIds: (n.sourceIds || []).filter((id) => id !== node.$id),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
))
|
||||||
|
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, node.$id)
|
||||||
|
setNodes((prev) => prev
|
||||||
|
.filter((n) => n.$id !== node.$id)
|
||||||
|
.map((n) => ((n.sourceIds || []).includes(node.$id)
|
||||||
|
? { ...n, sourceIds: n.sourceIds.filter((id) => id !== node.$id) }
|
||||||
|
: n))
|
||||||
|
)
|
||||||
|
return { success: true }
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kante anlegen: immer vorwaerts (Zielspalte > Quellspalte), keine Duplikate. */
|
||||||
|
const connectNodes = async (source, target) => {
|
||||||
|
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
|
||||||
|
if (source.$id === target.$id) return { success: false, error: 'Knoten kann nicht mit sich selbst verbunden werden.' }
|
||||||
|
if (target.column <= source.column) {
|
||||||
|
return { success: false, error: 'Verbindungen laufen immer vorwaerts (nach rechts).' }
|
||||||
|
}
|
||||||
|
if ((target.sourceIds || []).includes(source.$id)) {
|
||||||
|
return { success: false, error: 'Diese Verbindung existiert bereits.' }
|
||||||
|
}
|
||||||
|
return updateNode(target.$id, { sourceIds: [...(target.sourceIds || []), source.$id] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const disconnectNodes = async (sourceId, target) => {
|
||||||
|
return updateNode(target.$id, { sourceIds: (target.sourceIds || []).filter((id) => id !== sourceId) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Statuswechsel inkl. Worksheet-Log am Projektticket; meldet, ob alle Ziele fertig sind. */
|
||||||
|
const setNodeStatus = async (roadmap, node, newStatus, user) => {
|
||||||
|
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
|
||||||
|
const oldStatus = node.status || NODE_STATUS.OPEN
|
||||||
|
if (oldStatus === newStatus) return { success: true, allGoalsDone: false }
|
||||||
|
const result = await updateNode(node.$id, { status: newStatus })
|
||||||
|
if (!result.success) return result
|
||||||
|
try {
|
||||||
|
await logNodeStatusWorksheet(roadmap, node, oldStatus, newStatus, user)
|
||||||
|
} catch {
|
||||||
|
/* Verlaufslog ist optional - Statuswechsel bleibt gueltig */
|
||||||
|
}
|
||||||
|
const updatedNodes = nodes.map((n) => (n.$id === node.$id ? { ...n, status: newStatus } : n))
|
||||||
|
return { success: true, data: result.data, allGoalsDone: allGoalsDone(updatedNodes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes, loading, error, setNodes,
|
||||||
|
fetchNodes, createNode, updateNode, deleteNode,
|
||||||
|
connectNodes, disconnectNodes, setNodeStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/hooks/useRoadmaps.js
Normal file
100
src/hooks/useRoadmaps.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { databases, DATABASE_ID, COLLECTIONS, Query, isDemoMode } from '../lib/appwrite'
|
||||||
|
import {
|
||||||
|
createRoadmapPlan,
|
||||||
|
deleteRoadmapPlan,
|
||||||
|
completeRoadmap,
|
||||||
|
fetchRoadmapByTicketId,
|
||||||
|
} from '../lib/roadmaps'
|
||||||
|
|
||||||
|
export function useRoadmaps() {
|
||||||
|
const [roadmaps, setRoadmaps] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
if (isDemoMode) return []
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const response = await databases.listDocuments(
|
||||||
|
DATABASE_ID, COLLECTIONS.ROADMAPS,
|
||||||
|
[Query.orderDesc('$createdAt'), Query.limit(500)]
|
||||||
|
)
|
||||||
|
setRoadmaps(response.documents)
|
||||||
|
return response.documents
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
return []
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchById = useCallback(async (roadmapId) => {
|
||||||
|
if (!roadmapId || isDemoMode) return null
|
||||||
|
try {
|
||||||
|
return await databases.getDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmapId)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchByTicketId = useCallback(async (ticketId) => {
|
||||||
|
if (isDemoMode) return null
|
||||||
|
try {
|
||||||
|
return await fetchRoadmapByTicketId(ticketId)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const createRoadmap = async (project, data, user) => {
|
||||||
|
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
|
||||||
|
try {
|
||||||
|
const result = await createRoadmapPlan(project, data, user)
|
||||||
|
setRoadmaps((prev) => [result.roadmap, ...prev])
|
||||||
|
return { success: true, data: result }
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRoadmap = async (roadmapId, data) => {
|
||||||
|
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
|
||||||
|
try {
|
||||||
|
const doc = await databases.updateDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmapId, {
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
setRoadmaps((prev) => prev.map((r) => (r.$id === roadmapId ? doc : r)))
|
||||||
|
return { success: true, data: doc }
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteRoadmap = async (roadmap, user) => {
|
||||||
|
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
|
||||||
|
try {
|
||||||
|
await deleteRoadmapPlan(roadmap, user)
|
||||||
|
setRoadmaps((prev) => prev.filter((r) => r.$id !== roadmap.$id))
|
||||||
|
return { success: true }
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const completePlan = async (roadmap, user) => {
|
||||||
|
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
|
||||||
|
try {
|
||||||
|
const updated = await completeRoadmap(roadmap, user)
|
||||||
|
setRoadmaps((prev) => prev.map((r) => (r.$id === updated.$id ? updated : r)))
|
||||||
|
return { success: true, data: updated }
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { roadmaps, loading, error, fetchAll, fetchById, fetchByTicketId, createRoadmap, updateRoadmap, deleteRoadmap, completePlan }
|
||||||
|
}
|
||||||
84
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 (filters.type && filters.type.length > 0) {
|
if (activeFilters.type && activeFilters.type.length > 0) {
|
||||||
if (filters.type.length === 1) {
|
queries.push(Query.equal('type', activeFilters.type))
|
||||||
queries.push(Query.equal('type', filters.type[0]))
|
|
||||||
}
|
|
||||||
// Für mehrere Werte: Clientseitig filtern
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.priority && filters.priority.length > 0) {
|
if (activeFilters.priority && activeFilters.priority.length > 0) {
|
||||||
if (filters.priority.length === 1) {
|
queries.push(Query.equal('priority', activeFilters.priority))
|
||||||
queries.push(Query.equal('priority', filters.priority[0]))
|
|
||||||
}
|
|
||||||
// Für mehrere Werte: Clientseitig filtern
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.customerId) {
|
if (activeFilters.customerId) {
|
||||||
queries.push(Query.equal('customerId', filters.customerId))
|
queries.push(Query.equal('customerId', activeFilters.customerId))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.assignedTo) {
|
if (activeFilters.assignedTo) {
|
||||||
queries.push(Query.equal('assignedTo', filters.assignedTo))
|
queries.push(Query.equal('assignedTo', activeFilters.assignedTo))
|
||||||
|
}
|
||||||
|
|
||||||
|
// WOID-Direktsuche (z. B. Deep-Link aus den Leads: /tickets?woid=123)
|
||||||
|
if (activeFilters.woid) {
|
||||||
|
queries.push(Query.equal('woid', [String(activeFilters.woid)]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug: Zeige Collection ID
|
// Debug: Zeige Collection ID
|
||||||
@@ -89,22 +164,7 @@ export function useWorkorders(filters = {}) {
|
|||||||
queries
|
queries
|
||||||
)
|
)
|
||||||
|
|
||||||
// Clientseitige Filterung für Arrays (da Query.or() nicht verfügbar ist)
|
setWorkorders(response.documents)
|
||||||
let filteredDocs = response.documents
|
|
||||||
|
|
||||||
if (filters.status && filters.status.length > 1) {
|
|
||||||
filteredDocs = filteredDocs.filter(doc => filters.status.includes(doc.status))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.type && filters.type.length > 1) {
|
|
||||||
filteredDocs = filteredDocs.filter(doc => filters.type.includes(doc.type))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.priority && filters.priority.length > 1) {
|
|
||||||
filteredDocs = filteredDocs.filter(doc => filters.priority.includes(doc.priority))
|
|
||||||
}
|
|
||||||
|
|
||||||
setWorkorders(filteredDocs)
|
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let errorMessage = err.message || 'Fehler beim Laden der Tickets'
|
let errorMessage = err.message || 'Fehler beim Laden der Tickets'
|
||||||
@@ -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,47 @@ export const storage = new Storage(client)
|
|||||||
|
|
||||||
export const DATABASE_ID = import.meta.env.VITE_APPWRITE_DATABASE_ID || 'woms-database'
|
export const DATABASE_ID = import.meta.env.VITE_APPWRITE_DATABASE_ID || 'woms-database'
|
||||||
|
|
||||||
// Collection IDs - Verwende die tatsächlichen Collection IDs aus Appwrite!
|
|
||||||
export const COLLECTIONS = {
|
export const COLLECTIONS = {
|
||||||
WORKORDERS: '6943bf7d001901baa60c', // Collection ID für workorders
|
WORKORDERS: 'workorders',
|
||||||
CONFIG: 'config', // Collection ID für Admin-Konfiguration (wird erstellt)
|
CONFIG: 'config',
|
||||||
CUSTOMERS: '694bd1fb002b2e583d13', // Collection ID für customers
|
CUSTOMERS: 'customers',
|
||||||
EMPLOYEES: '695280510031c6c6153b', // Collection ID für employees
|
EMPLOYEES: 'employees',
|
||||||
WORKSHEETS: '6952dbcf0032a92e1168', // Collection ID für worksheets ✅
|
WORKSHEETS: 'worksheets',
|
||||||
USERS: 'users',
|
USERS: 'users',
|
||||||
ATTACHMENTS: 'attachments'
|
ATTACHMENTS: 'attachments',
|
||||||
|
WEBSITE_PROJECTS: 'websiteProjects',
|
||||||
|
LEADS: 'leads',
|
||||||
|
LEAD_SETTINGS: 'leadSettings',
|
||||||
|
LEAD_PIPELINE: 'leadPipeline',
|
||||||
|
ROADMAPS: 'roadmaps',
|
||||||
|
ROADMAP_NODES: 'roadmapNodes',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BUCKET_ID = 'woms-attachments'
|
export const WEBPAGE_TICKET_TYPE = 'Webpage'
|
||||||
|
export const PROJECT_TICKET_TYPE = 'Project'
|
||||||
|
export const PREVIEW_TEMPLATE_NAME = 'webklar-preview-template'
|
||||||
|
|
||||||
|
export const BUCKET_ID = import.meta.env.VITE_APPWRITE_BUCKET_ID || 'woms-attachments'
|
||||||
|
|
||||||
|
/** Prüft ob vermutlich eine Session existiert (cookieFallback oder Same-Origin-Cookie). */
|
||||||
|
export function hasAppwriteSession() {
|
||||||
|
if (typeof window === 'undefined' || !projectId) return false
|
||||||
|
try {
|
||||||
|
const apiHost = new URL(endpoint).hostname
|
||||||
|
if (apiHost === window.location.hostname) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem('cookieFallback')
|
||||||
|
if (!raw) return false
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return Boolean(parsed?.[`a_session_${projectId}`])
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
export { ID, Query }
|
export { ID, Query }
|
||||||
|
|||||||
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)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
292
src/lib/roadmaps.js
Normal file
292
src/lib/roadmaps.js
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { databases, DATABASE_ID, COLLECTIONS, PROJECT_TICKET_TYPE, Query, ID } from './appwrite'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roadmaps: projektspezifische Plaene als Steckbrett (Spalten-Raster).
|
||||||
|
* Ein Dokument pro Knoten; Kanten als sourceIds (Vorgaenger) am Zielknoten.
|
||||||
|
* Genau 1 aktiver Plan pro Projekt (Pruefung hier, nicht per Index erzwingbar).
|
||||||
|
* Jeder Plan erzeugt automatisch ein Projektticket (Vorbild: createAcquisitionTicket in leads.js).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ROADMAP_STATUS = { ACTIVE: 'active', COMPLETED: 'completed' }
|
||||||
|
export const NODE_STATUS = { OPEN: 'open', IN_PROGRESS: 'in_progress', DONE: 'done' }
|
||||||
|
export const NODE_KIND = { START: 'start', STEP: 'step', GOAL: 'goal' }
|
||||||
|
|
||||||
|
export const NODE_STATUS_LABELS = {
|
||||||
|
open: 'Offen',
|
||||||
|
in_progress: 'In Arbeit',
|
||||||
|
done: 'Fertig',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fortschritt in Prozent: fertige Knoten / alle Knoten ausser Start. */
|
||||||
|
export function computeProgress(nodes) {
|
||||||
|
const relevant = (nodes || []).filter((n) => n.kind !== NODE_KIND.START)
|
||||||
|
if (!relevant.length) return { percent: 0, done: 0, total: 0 }
|
||||||
|
const done = relevant.filter((n) => n.status === NODE_STATUS.DONE).length
|
||||||
|
return { percent: Math.round((done / relevant.length) * 100), done, total: relevant.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allGoalsDone(nodes) {
|
||||||
|
const goals = (nodes || []).filter((n) => n.kind === NODE_KIND.GOAL)
|
||||||
|
return goals.length > 0 && goals.every((n) => n.status === NODE_STATUS.DONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNodeOverdue(node) {
|
||||||
|
if (!node?.deadline || node.status === NODE_STATUS.DONE) return false
|
||||||
|
return node.deadline < new Date().toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktiver Plan eines Projekts (oder null). */
|
||||||
|
export async function fetchActiveRoadmapByProject(projectId) {
|
||||||
|
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.ROADMAPS, [
|
||||||
|
Query.equal('projectId', projectId),
|
||||||
|
Query.equal('status', ROADMAP_STATUS.ACTIVE),
|
||||||
|
Query.limit(1),
|
||||||
|
])
|
||||||
|
return res.documents[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRoadmapByTicketId(ticketId) {
|
||||||
|
if (!ticketId) return null
|
||||||
|
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.ROADMAPS, [
|
||||||
|
Query.equal('ticketId', ticketId),
|
||||||
|
Query.limit(1),
|
||||||
|
])
|
||||||
|
return res.documents[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRoadmapNodes(roadmapId) {
|
||||||
|
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, [
|
||||||
|
Query.equal('roadmapId', roadmapId),
|
||||||
|
Query.limit(500),
|
||||||
|
])
|
||||||
|
return res.documents
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nextWoid() {
|
||||||
|
try {
|
||||||
|
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
|
||||||
|
Query.orderDesc('$createdAt'),
|
||||||
|
Query.limit(200),
|
||||||
|
])
|
||||||
|
const nums = res.documents.map((d) => parseInt(d.woid)).filter((n) => !isNaN(n) && n > 0)
|
||||||
|
return String((nums.length ? Math.max(...nums) : 9999) + 1)
|
||||||
|
} catch {
|
||||||
|
return String(10000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nextWsid() {
|
||||||
|
try {
|
||||||
|
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKSHEETS, [
|
||||||
|
Query.orderDesc('wsid'),
|
||||||
|
Query.limit(1),
|
||||||
|
])
|
||||||
|
const highest = parseInt(res.documents[0]?.wsid)
|
||||||
|
return String(isNaN(highest) ? 100000 : highest + 1)
|
||||||
|
} catch {
|
||||||
|
return String(100000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Automatisches Projektticket fuer einen neuen Plan. */
|
||||||
|
async function createRoadmapTicket(roadmap, project, user) {
|
||||||
|
const woid = await nextWoid()
|
||||||
|
const data = {
|
||||||
|
topic: `Projekt: ${project.projectName}`,
|
||||||
|
status: 'Open',
|
||||||
|
priority: 2,
|
||||||
|
woid,
|
||||||
|
type: PROJECT_TICKET_TYPE,
|
||||||
|
systemType: 'n/a',
|
||||||
|
serviceType: 'Remote',
|
||||||
|
assignedTo: user?.$id || '',
|
||||||
|
details:
|
||||||
|
`Automatisch erstelltes Projektticket fuer die Roadmap "${roadmap.title}" ` +
|
||||||
|
`zum Projekt "${project.projectName}".\n\n` +
|
||||||
|
`Der Planfortschritt wird hier getrackt: Jede Statusaenderung eines Roadmap-Knotens ` +
|
||||||
|
`erzeugt einen Verlaufseintrag (Worksheet). Sind alle Ziel-Knoten fertig, wird das ` +
|
||||||
|
`Ticket geschlossen und der Plan archiviert.`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
if (project.customerId) {
|
||||||
|
data.customerId = project.customerId
|
||||||
|
if (roadmap.customerName) data.customerName = roadmap.customerName
|
||||||
|
}
|
||||||
|
return databases.createDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ID.unique(), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt einen neuen Plan an: Roadmap-Dokument + Start-Knoten + Ziel-Knoten + Auto-Ticket.
|
||||||
|
* Wirft, wenn das Projekt bereits einen aktiven Plan hat.
|
||||||
|
*/
|
||||||
|
export async function createRoadmapPlan(project, { title, goalTitle }, user) {
|
||||||
|
const existing = await fetchActiveRoadmapByProject(project.$id)
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`Das Projekt "${project.projectName}" hat bereits einen aktiven Plan.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let customerName = ''
|
||||||
|
if (project.customerId) {
|
||||||
|
try {
|
||||||
|
const customer = await databases.getDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, project.customerId)
|
||||||
|
customerName = customer.name || ''
|
||||||
|
} catch {
|
||||||
|
/* Kunde optional */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const columnCount = 3
|
||||||
|
const roadmap = await databases.createDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, ID.unique(), {
|
||||||
|
projectId: project.$id,
|
||||||
|
projectName: project.projectName || '',
|
||||||
|
customerId: project.customerId || '',
|
||||||
|
customerName,
|
||||||
|
title: title || `Plan: ${project.projectName}`,
|
||||||
|
status: ROADMAP_STATUS.ACTIVE,
|
||||||
|
columnCount,
|
||||||
|
createdBy: user?.$id || '',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
databases.createDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, ID.unique(), {
|
||||||
|
roadmapId: roadmap.$id,
|
||||||
|
title: 'Start',
|
||||||
|
kind: NODE_KIND.START,
|
||||||
|
column: 0,
|
||||||
|
row: 0,
|
||||||
|
status: NODE_STATUS.OPEN,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}),
|
||||||
|
databases.createDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, ID.unique(), {
|
||||||
|
roadmapId: roadmap.$id,
|
||||||
|
title: goalTitle || 'Ziel',
|
||||||
|
kind: NODE_KIND.GOAL,
|
||||||
|
column: columnCount - 1,
|
||||||
|
row: 0,
|
||||||
|
status: NODE_STATUS.OPEN,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
let ticket = null
|
||||||
|
try {
|
||||||
|
ticket = await createRoadmapTicket(roadmap, project, user)
|
||||||
|
await databases.updateDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmap.$id, {
|
||||||
|
ticketId: ticket.$id,
|
||||||
|
woid: ticket.woid,
|
||||||
|
})
|
||||||
|
roadmap.ticketId = ticket.$id
|
||||||
|
roadmap.woid = ticket.woid
|
||||||
|
} catch {
|
||||||
|
/* Ticket ist optional - Plan bleibt bestehen */
|
||||||
|
}
|
||||||
|
return { roadmap, ticket }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verlaufseintrag (Worksheet-Kommentar) am Projektticket, ohne den Ticket-Status zu aendern. */
|
||||||
|
export async function logRoadmapWorksheet(roadmap, detailsText, user) {
|
||||||
|
if (!roadmap?.ticketId) return null
|
||||||
|
let ticket
|
||||||
|
try {
|
||||||
|
ticket = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, roadmap.ticketId)
|
||||||
|
} catch {
|
||||||
|
return null // Ticket geloescht -> kein Log
|
||||||
|
}
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
const wsid = await nextWsid()
|
||||||
|
return databases.createDocument(DATABASE_ID, COLLECTIONS.WORKSHEETS, ID.unique(), {
|
||||||
|
wsid,
|
||||||
|
woid: ticket.woid || roadmap.woid || '',
|
||||||
|
workorderId: ticket.$id,
|
||||||
|
employeeId: user?.$id || 'system',
|
||||||
|
employeeName: user?.name || user?.email || 'System',
|
||||||
|
serviceType: 'Remote',
|
||||||
|
oldStatus: ticket.status,
|
||||||
|
newStatus: ticket.status,
|
||||||
|
totalTime: 0,
|
||||||
|
startDate: today,
|
||||||
|
endDate: today,
|
||||||
|
details: detailsText,
|
||||||
|
isComment: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Knoten-Statuswechsel im Ticketverlauf dokumentieren. */
|
||||||
|
export async function logNodeStatusWorksheet(roadmap, node, oldStatus, newStatus, user) {
|
||||||
|
const from = NODE_STATUS_LABELS[oldStatus] || oldStatus || '-'
|
||||||
|
const to = NODE_STATUS_LABELS[newStatus] || newStatus
|
||||||
|
return logRoadmapWorksheet(roadmap, `Roadmap-Knoten "${node.title}": ${from} -> ${to}`, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan abschliessen: Roadmap archivieren, Ticket schliessen (falls noch offen)
|
||||||
|
* und den Abschluss im Verlauf dokumentieren.
|
||||||
|
*/
|
||||||
|
export async function completeRoadmap(roadmap, user) {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const updated = await databases.updateDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmap.$id, {
|
||||||
|
status: ROADMAP_STATUS.COMPLETED,
|
||||||
|
completedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
if (roadmap.ticketId) {
|
||||||
|
try {
|
||||||
|
const ticket = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, roadmap.ticketId)
|
||||||
|
if (ticket.status !== 'Closed') {
|
||||||
|
const wsid = await nextWsid()
|
||||||
|
const today = now.slice(0, 10)
|
||||||
|
await databases.createDocument(DATABASE_ID, COLLECTIONS.WORKSHEETS, ID.unique(), {
|
||||||
|
wsid,
|
||||||
|
woid: ticket.woid || '',
|
||||||
|
workorderId: ticket.$id,
|
||||||
|
employeeId: user?.$id || 'system',
|
||||||
|
employeeName: user?.name || user?.email || 'System',
|
||||||
|
serviceType: 'Remote',
|
||||||
|
oldStatus: ticket.status,
|
||||||
|
newStatus: 'Closed',
|
||||||
|
totalTime: 0,
|
||||||
|
startDate: today,
|
||||||
|
endDate: today,
|
||||||
|
details: `Roadmap "${roadmap.title}" abgeschlossen: alle Ziel-Knoten fertig. Ticket automatisch geschlossen.`,
|
||||||
|
isComment: true,
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
await databases.updateDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ticket.$id, { status: 'Closed' })
|
||||||
|
} else {
|
||||||
|
await logRoadmapWorksheet({ ...roadmap, status: ROADMAP_STATUS.COMPLETED }, `Roadmap "${roadmap.title}" abgeschlossen (Ticket war bereits geschlossen).`, user)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* Ticket fehlt -> nur archivieren */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktiven Plan loeschen (destruktiv): alle Knoten + Roadmap entfernen,
|
||||||
|
* Auto-Ticket auf Cancelled setzen. Abgeschlossene Plaene werden nie geloescht.
|
||||||
|
*/
|
||||||
|
export async function deleteRoadmapPlan(roadmap, user) {
|
||||||
|
const nodes = await fetchRoadmapNodes(roadmap.$id)
|
||||||
|
await logRoadmapWorksheet(roadmap, `Roadmap "${roadmap.title}" geloescht. Ticket wird abgebrochen.`, user)
|
||||||
|
for (const node of nodes) {
|
||||||
|
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, node.$id)
|
||||||
|
}
|
||||||
|
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmap.$id)
|
||||||
|
if (roadmap.ticketId) {
|
||||||
|
try {
|
||||||
|
const ticket = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, roadmap.ticketId)
|
||||||
|
if (ticket.status !== 'Closed' && ticket.status !== 'Cancelled') {
|
||||||
|
await databases.updateDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ticket.$id, { status: 'Cancelled' })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* Ticket fehlt -> nichts zu tun */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
456
src/pages/AdminPage.css
Normal file
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user