Compare commits
41 Commits
better-des
...
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 | |||
| cb110a184b | |||
| ee7c866616 |
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
|
||||||
|
}
|
||||||
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`
|
||||||
62
node_modules/.vite/deps/_metadata.json
generated
vendored
62
node_modules/.vite/deps/_metadata.json
generated
vendored
@@ -1,109 +1,109 @@
|
|||||||
{
|
{
|
||||||
"hash": "ba7c7150",
|
"hash": "91e90cf6",
|
||||||
"configHash": "6a220e5a",
|
"configHash": "eaf678ca",
|
||||||
"lockfileHash": "a10f8d29",
|
"lockfileHash": "4b2b8d7d",
|
||||||
"browserHash": "db384cdc",
|
"browserHash": "114e306f",
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"react": {
|
"react": {
|
||||||
"src": "../../react/index.js",
|
"src": "../../react/index.js",
|
||||||
"file": "react.js",
|
"file": "react.js",
|
||||||
"fileHash": "d41cd819",
|
"fileHash": "c1394b31",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"react-dom": {
|
"react-dom": {
|
||||||
"src": "../../react-dom/index.js",
|
"src": "../../react-dom/index.js",
|
||||||
"file": "react-dom.js",
|
"file": "react-dom.js",
|
||||||
"fileHash": "02c7f745",
|
"fileHash": "e0cfaa25",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"react/jsx-dev-runtime": {
|
"react/jsx-dev-runtime": {
|
||||||
"src": "../../react/jsx-dev-runtime.js",
|
"src": "../../react/jsx-dev-runtime.js",
|
||||||
"file": "react_jsx-dev-runtime.js",
|
"file": "react_jsx-dev-runtime.js",
|
||||||
"fileHash": "beec6a15",
|
"fileHash": "4e6552af",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"react/jsx-runtime": {
|
"react/jsx-runtime": {
|
||||||
"src": "../../react/jsx-runtime.js",
|
"src": "../../react/jsx-runtime.js",
|
||||||
"file": "react_jsx-runtime.js",
|
"file": "react_jsx-runtime.js",
|
||||||
"fileHash": "f6c3e40e",
|
"fileHash": "326b9f6e",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"@tabler/icons-react": {
|
"@tabler/icons-react": {
|
||||||
"src": "../../@tabler/icons-react/dist/esm/tabler-icons-react.mjs",
|
"src": "../../@tabler/icons-react/dist/esm/tabler-icons-react.mjs",
|
||||||
"file": "@tabler_icons-react.js",
|
"file": "@tabler_icons-react.js",
|
||||||
"fileHash": "11d925b5",
|
"fileHash": "ef17992e",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"appwrite": {
|
"appwrite": {
|
||||||
"src": "../../appwrite/dist/esm/sdk.js",
|
"src": "../../appwrite/dist/esm/sdk.js",
|
||||||
"file": "appwrite.js",
|
"file": "appwrite.js",
|
||||||
"fileHash": "e5052173",
|
"fileHash": "e3207492",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"clsx": {
|
"clsx": {
|
||||||
"src": "../../clsx/dist/clsx.mjs",
|
"src": "../../clsx/dist/clsx.mjs",
|
||||||
"file": "clsx.js",
|
"file": "clsx.js",
|
||||||
"fileHash": "7fc4f217",
|
"fileHash": "438277fe",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"date-fns": {
|
"date-fns": {
|
||||||
"src": "../../date-fns/esm/index.js",
|
"src": "../../date-fns/esm/index.js",
|
||||||
"file": "date-fns.js",
|
"file": "date-fns.js",
|
||||||
"fileHash": "371588f0",
|
"fileHash": "c8cdb3c6",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"date-fns/locale": {
|
"date-fns/locale": {
|
||||||
"src": "../../date-fns/esm/locale/index.js",
|
"src": "../../date-fns/esm/locale/index.js",
|
||||||
"file": "date-fns_locale.js",
|
"file": "date-fns_locale.js",
|
||||||
"fileHash": "927376cc",
|
"fileHash": "affdbcf7",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"motion/react": {
|
"motion/react": {
|
||||||
"src": "../../motion/dist/es/react.mjs",
|
"src": "../../motion/dist/es/react.mjs",
|
||||||
"file": "motion_react.js",
|
"file": "motion_react.js",
|
||||||
"fileHash": "03c47f11",
|
"fileHash": "d06adb28",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"postprocessing": {
|
"postprocessing": {
|
||||||
"src": "../../postprocessing/build/index.js",
|
"src": "../../postprocessing/build/index.js",
|
||||||
"file": "postprocessing.js",
|
"file": "postprocessing.js",
|
||||||
"fileHash": "6a1af3d4",
|
"fileHash": "a05d80d0",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"react-dom/client": {
|
"react-dom/client": {
|
||||||
"src": "../../react-dom/client.js",
|
"src": "../../react-dom/client.js",
|
||||||
"file": "react-dom_client.js",
|
"file": "react-dom_client.js",
|
||||||
"fileHash": "13d89711",
|
"fileHash": "35e13db7",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"react-icons/fa": {
|
"react-icons/fa": {
|
||||||
"src": "../../react-icons/fa/index.esm.js",
|
"src": "../../react-icons/fa/index.esm.js",
|
||||||
"file": "react-icons_fa.js",
|
"file": "react-icons_fa.js",
|
||||||
"fileHash": "ce6891d0",
|
"fileHash": "b39d751a",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"react-icons/fa6": {
|
"react-icons/fa6": {
|
||||||
"src": "../../react-icons/fa6/index.esm.js",
|
"src": "../../react-icons/fa6/index.esm.js",
|
||||||
"file": "react-icons_fa6.js",
|
"file": "react-icons_fa6.js",
|
||||||
"fileHash": "836a5ede",
|
"fileHash": "ee91d98a",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"react-router-dom": {
|
"react-router-dom": {
|
||||||
"src": "../../react-router-dom/dist/index.js",
|
"src": "../../react-router-dom/dist/index.js",
|
||||||
"file": "react-router-dom.js",
|
"file": "react-router-dom.js",
|
||||||
"fileHash": "65b7bd91",
|
"fileHash": "96b74880",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"tailwind-merge": {
|
"tailwind-merge": {
|
||||||
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
|
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
|
||||||
"file": "tailwind-merge.js",
|
"file": "tailwind-merge.js",
|
||||||
"fileHash": "8ea254d4",
|
"fileHash": "422eebfe",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"three": {
|
"three": {
|
||||||
"src": "../../three/build/three.module.js",
|
"src": "../../three/build/three.module.js",
|
||||||
"file": "three.js",
|
"file": "three.js",
|
||||||
"fileHash": "c500eef6",
|
"fileHash": "7ea78180",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -111,20 +111,20 @@
|
|||||||
"chunk-IFCYBMKG": {
|
"chunk-IFCYBMKG": {
|
||||||
"file": "chunk-IFCYBMKG.js"
|
"file": "chunk-IFCYBMKG.js"
|
||||||
},
|
},
|
||||||
"chunk-7VTGDDTZ": {
|
"chunk-DQJKJRV5": {
|
||||||
"file": "chunk-7VTGDDTZ.js"
|
"file": "chunk-DQJKJRV5.js"
|
||||||
},
|
},
|
||||||
"chunk-TDH2IRYZ": {
|
"chunk-PJEEZAML": {
|
||||||
"file": "chunk-TDH2IRYZ.js"
|
"file": "chunk-PJEEZAML.js"
|
||||||
},
|
},
|
||||||
"chunk-NMLHVZ76": {
|
"chunk-6PXSGDAH": {
|
||||||
"file": "chunk-NMLHVZ76.js"
|
"file": "chunk-6PXSGDAH.js"
|
||||||
},
|
},
|
||||||
"chunk-QRULMDK5": {
|
"chunk-DRWLMN53": {
|
||||||
"file": "chunk-QRULMDK5.js"
|
"file": "chunk-DRWLMN53.js"
|
||||||
},
|
},
|
||||||
"chunk-FSI7PPCM": {
|
"chunk-SJKHQ62W": {
|
||||||
"file": "chunk-FSI7PPCM.js"
|
"file": "chunk-SJKHQ62W.js"
|
||||||
},
|
},
|
||||||
"chunk-G3PMV62Z": {
|
"chunk-G3PMV62Z": {
|
||||||
"file": "chunk-G3PMV62Z.js"
|
"file": "chunk-G3PMV62Z.js"
|
||||||
|
|||||||
2
node_modules/.vite/deps/appwrite.js.map
generated
vendored
2
node_modules/.vite/deps/appwrite.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
node_modules/.vite/deps/date-fns.js
generated
vendored
2
node_modules/.vite/deps/date-fns.js
generated
vendored
@@ -256,7 +256,7 @@ import {
|
|||||||
weeksToDays,
|
weeksToDays,
|
||||||
yearsToMonths,
|
yearsToMonths,
|
||||||
yearsToQuarters
|
yearsToQuarters
|
||||||
} from "./chunk-FSI7PPCM.js";
|
} from "./chunk-SJKHQ62W.js";
|
||||||
import "./chunk-G3PMV62Z.js";
|
import "./chunk-G3PMV62Z.js";
|
||||||
export {
|
export {
|
||||||
add,
|
add,
|
||||||
|
|||||||
2
node_modules/.vite/deps/date-fns_locale.js
generated
vendored
2
node_modules/.vite/deps/date-fns_locale.js
generated
vendored
@@ -11,7 +11,7 @@ import {
|
|||||||
requiredArgs,
|
requiredArgs,
|
||||||
startOfUTCWeek,
|
startOfUTCWeek,
|
||||||
toDate
|
toDate
|
||||||
} from "./chunk-FSI7PPCM.js";
|
} from "./chunk-SJKHQ62W.js";
|
||||||
import "./chunk-G3PMV62Z.js";
|
import "./chunk-G3PMV62Z.js";
|
||||||
|
|
||||||
// node_modules/date-fns/esm/locale/af/_lib/formatDistance/index.js
|
// node_modules/date-fns/esm/locale/af/_lib/formatDistance/index.js
|
||||||
|
|||||||
2
node_modules/.vite/deps/date-fns_locale.js.map
generated
vendored
2
node_modules/.vite/deps/date-fns_locale.js.map
generated
vendored
File diff suppressed because one or more lines are too long
4
node_modules/.vite/deps/react-dom.js
generated
vendored
4
node_modules/.vite/deps/react-dom.js
generated
vendored
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
require_react_dom
|
require_react_dom
|
||||||
} from "./chunk-TDH2IRYZ.js";
|
} from "./chunk-PJEEZAML.js";
|
||||||
import "./chunk-QRULMDK5.js";
|
import "./chunk-DRWLMN53.js";
|
||||||
import "./chunk-G3PMV62Z.js";
|
import "./chunk-G3PMV62Z.js";
|
||||||
export default require_react_dom();
|
export default require_react_dom();
|
||||||
//# sourceMappingURL=react-dom.js.map
|
//# sourceMappingURL=react-dom.js.map
|
||||||
|
|||||||
4
node_modules/.vite/deps/react-dom_client.js
generated
vendored
4
node_modules/.vite/deps/react-dom_client.js
generated
vendored
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
require_react_dom
|
require_react_dom
|
||||||
} from "./chunk-TDH2IRYZ.js";
|
} from "./chunk-PJEEZAML.js";
|
||||||
import "./chunk-QRULMDK5.js";
|
import "./chunk-DRWLMN53.js";
|
||||||
import {
|
import {
|
||||||
__commonJS
|
__commonJS
|
||||||
} from "./chunk-G3PMV62Z.js";
|
} from "./chunk-G3PMV62Z.js";
|
||||||
|
|||||||
2
node_modules/.vite/deps/react-dom_client.js.map
generated
vendored
2
node_modules/.vite/deps/react-dom_client.js.map
generated
vendored
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"sources": ["../../react-dom/client.js"],
|
"sources": ["../../react-dom/client.js"],
|
||||||
"sourcesContent": ["'use strict';\r\n\r\nvar m = require('react-dom');\r\nif (process.env.NODE_ENV === 'production') {\r\n exports.createRoot = m.createRoot;\r\n exports.hydrateRoot = m.hydrateRoot;\r\n} else {\r\n var i = m.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;\r\n exports.createRoot = function(c, o) {\r\n i.usingClientEntryPoint = true;\r\n try {\r\n return m.createRoot(c, o);\r\n } finally {\r\n i.usingClientEntryPoint = false;\r\n }\r\n };\r\n exports.hydrateRoot = function(c, h, o) {\r\n i.usingClientEntryPoint = true;\r\n try {\r\n return m.hydrateRoot(c, h, o);\r\n } finally {\r\n i.usingClientEntryPoint = false;\r\n }\r\n };\r\n}\r\n"],
|
"sourcesContent": ["'use strict';\n\nvar m = require('react-dom');\nif (process.env.NODE_ENV === 'production') {\n exports.createRoot = m.createRoot;\n exports.hydrateRoot = m.hydrateRoot;\n} else {\n var i = m.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;\n exports.createRoot = function(c, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.createRoot(c, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n exports.hydrateRoot = function(c, h, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.hydrateRoot(c, h, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n}\n"],
|
||||||
"mappings": ";;;;;;;;;AAAA;AAAA;AAEA,QAAI,IAAI;AACR,QAAI,OAAuC;AACzC,cAAQ,aAAa,EAAE;AACvB,cAAQ,cAAc,EAAE;AAAA,IAC1B,OAAO;AACD,UAAI,EAAE;AACV,cAAQ,aAAa,SAAS,GAAG,GAAG;AAClC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,WAAW,GAAG,CAAC;AAAA,QAC1B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AACA,cAAQ,cAAc,SAAS,GAAG,GAAG,GAAG;AACtC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,YAAY,GAAG,GAAG,CAAC;AAAA,QAC9B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AAAA,IACF;AAjBM;AAAA;AAAA;",
|
"mappings": ";;;;;;;;;AAAA;AAAA;AAEA,QAAI,IAAI;AACR,QAAI,OAAuC;AACzC,cAAQ,aAAa,EAAE;AACvB,cAAQ,cAAc,EAAE;AAAA,IAC1B,OAAO;AACD,UAAI,EAAE;AACV,cAAQ,aAAa,SAAS,GAAG,GAAG;AAClC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,WAAW,GAAG,CAAC;AAAA,QAC1B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AACA,cAAQ,cAAc,SAAS,GAAG,GAAG,GAAG;AACtC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,YAAY,GAAG,GAAG,CAAC;AAAA,QAC9B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AAAA,IACF;AAjBM;AAAA;AAAA;",
|
||||||
"names": []
|
"names": []
|
||||||
}
|
}
|
||||||
|
|||||||
4
node_modules/.vite/deps/react-icons_fa.js
generated
vendored
4
node_modules/.vite/deps/react-icons_fa.js
generated
vendored
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
GenIcon
|
GenIcon
|
||||||
} from "./chunk-7VTGDDTZ.js";
|
} from "./chunk-DQJKJRV5.js";
|
||||||
import "./chunk-QRULMDK5.js";
|
import "./chunk-DRWLMN53.js";
|
||||||
import "./chunk-G3PMV62Z.js";
|
import "./chunk-G3PMV62Z.js";
|
||||||
|
|
||||||
// node_modules/react-icons/fa/index.esm.js
|
// node_modules/react-icons/fa/index.esm.js
|
||||||
|
|||||||
2
node_modules/.vite/deps/react-icons_fa.js.map
generated
vendored
2
node_modules/.vite/deps/react-icons_fa.js.map
generated
vendored
File diff suppressed because one or more lines are too long
4
node_modules/.vite/deps/react-icons_fa6.js
generated
vendored
4
node_modules/.vite/deps/react-icons_fa6.js
generated
vendored
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
GenIcon
|
GenIcon
|
||||||
} from "./chunk-7VTGDDTZ.js";
|
} from "./chunk-DQJKJRV5.js";
|
||||||
import "./chunk-QRULMDK5.js";
|
import "./chunk-DRWLMN53.js";
|
||||||
import "./chunk-G3PMV62Z.js";
|
import "./chunk-G3PMV62Z.js";
|
||||||
|
|
||||||
// node_modules/react-icons/fa6/index.esm.js
|
// node_modules/react-icons/fa6/index.esm.js
|
||||||
|
|||||||
2
node_modules/.vite/deps/react-icons_fa6.js.map
generated
vendored
2
node_modules/.vite/deps/react-icons_fa6.js.map
generated
vendored
File diff suppressed because one or more lines are too long
4
node_modules/.vite/deps/react-router-dom.js
generated
vendored
4
node_modules/.vite/deps/react-router-dom.js
generated
vendored
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
require_react_dom
|
require_react_dom
|
||||||
} from "./chunk-TDH2IRYZ.js";
|
} from "./chunk-PJEEZAML.js";
|
||||||
import {
|
import {
|
||||||
require_react
|
require_react
|
||||||
} from "./chunk-QRULMDK5.js";
|
} from "./chunk-DRWLMN53.js";
|
||||||
import {
|
import {
|
||||||
__toESM
|
__toESM
|
||||||
} from "./chunk-G3PMV62Z.js";
|
} from "./chunk-G3PMV62Z.js";
|
||||||
|
|||||||
2
node_modules/.vite/deps/react.js
generated
vendored
2
node_modules/.vite/deps/react.js
generated
vendored
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
require_react
|
require_react
|
||||||
} from "./chunk-QRULMDK5.js";
|
} from "./chunk-DRWLMN53.js";
|
||||||
import "./chunk-G3PMV62Z.js";
|
import "./chunk-G3PMV62Z.js";
|
||||||
export default require_react();
|
export default require_react();
|
||||||
//# sourceMappingURL=react.js.map
|
//# sourceMappingURL=react.js.map
|
||||||
|
|||||||
2
node_modules/.vite/deps/react_jsx-dev-runtime.js
generated
vendored
2
node_modules/.vite/deps/react_jsx-dev-runtime.js
generated
vendored
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
require_react
|
require_react
|
||||||
} from "./chunk-QRULMDK5.js";
|
} from "./chunk-DRWLMN53.js";
|
||||||
import {
|
import {
|
||||||
__commonJS
|
__commonJS
|
||||||
} from "./chunk-G3PMV62Z.js";
|
} from "./chunk-G3PMV62Z.js";
|
||||||
|
|||||||
2
node_modules/.vite/deps/react_jsx-dev-runtime.js.map
generated
vendored
2
node_modules/.vite/deps/react_jsx-dev-runtime.js.map
generated
vendored
File diff suppressed because one or more lines are too long
4
node_modules/.vite/deps/react_jsx-runtime.js
generated
vendored
4
node_modules/.vite/deps/react_jsx-runtime.js
generated
vendored
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
require_jsx_runtime
|
require_jsx_runtime
|
||||||
} from "./chunk-NMLHVZ76.js";
|
} from "./chunk-6PXSGDAH.js";
|
||||||
import "./chunk-QRULMDK5.js";
|
import "./chunk-DRWLMN53.js";
|
||||||
import "./chunk-G3PMV62Z.js";
|
import "./chunk-G3PMV62Z.js";
|
||||||
export default require_jsx_runtime();
|
export default require_jsx_runtime();
|
||||||
//# sourceMappingURL=react_jsx-runtime.js.map
|
//# sourceMappingURL=react_jsx-runtime.js.map
|
||||||
|
|||||||
1198
package-lock.json
generated
1198
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"
|
||||||
|
|||||||
102
src/App.jsx
102
src/App.jsx
@@ -1,49 +1,62 @@
|
|||||||
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Navigate to="/login" replace />
|
return <Navigate to="/login" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
return children
|
return children
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={user ? <Navigate to="/tickets" replace /> : <LoginPage />} />
|
<Route path="/login" element={user ? <Navigate to="/tickets" replace /> : <LoginPage />} />
|
||||||
<Route path="/" element={<Navigate to="/tickets" replace />} />
|
<Route path="/" element={<Navigate to="/tickets" replace />} />
|
||||||
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
|
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/leads" element={<ProtectedRoute><LeadsPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/finance" element={<ProtectedRoute><FinancePage /></ProtectedRoute>} />
|
||||||
|
<Route path="/documents" element={<ProtectedRoute><DocumentsPage /></ProtectedRoute>} />
|
||||||
<Route path="/dashboard" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
|
<Route path="/dashboard" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
|
||||||
<Route path="/assets" element={<ProtectedRoute><AssetsPage /></ProtectedRoute>} />
|
<Route path="/assets" element={<ProtectedRoute><AssetsPage /></ProtectedRoute>} />
|
||||||
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
|
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
|
||||||
<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>} />
|
||||||
@@ -53,74 +66,21 @@ function AppRoutes() {
|
|||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return <AppRoutes />
|
||||||
<>
|
}
|
||||||
{/* PixelBlast Background für Login-Seite */}
|
|
||||||
<div style={{
|
return (
|
||||||
position: 'fixed',
|
<div className="app-shell">
|
||||||
top: 0,
|
<Sidebar />
|
||||||
left: 0,
|
<div className="app-main">
|
||||||
width: '100vw',
|
<div className="app-scroll">
|
||||||
height: '100vh',
|
|
||||||
zIndex: 0,
|
|
||||||
background: '#1a202c'
|
|
||||||
}}>
|
|
||||||
<PixelBlast
|
|
||||||
color="#10b981"
|
|
||||||
variant="circle"
|
|
||||||
pixelSize={4}
|
|
||||||
patternScale={2}
|
|
||||||
patternDensity={0.8}
|
|
||||||
enableRipples={true}
|
|
||||||
rippleIntensityScale={1.5}
|
|
||||||
speed={0.3}
|
|
||||||
edgeFade={0.3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</div>
|
</div>
|
||||||
</>
|
<Footer />
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* PixelBlast Background für Haupt-App */}
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100vw',
|
|
||||||
height: '100vh',
|
|
||||||
zIndex: 0,
|
|
||||||
background: '#1a202c'
|
|
||||||
}}>
|
|
||||||
<PixelBlast
|
|
||||||
color="#10b981"
|
|
||||||
variant="circle"
|
|
||||||
pixelSize={4}
|
|
||||||
patternScale={2}
|
|
||||||
patternDensity={0.8}
|
|
||||||
enableRipples={true}
|
|
||||||
rippleIntensityScale={1.5}
|
|
||||||
speed={0.3}
|
|
||||||
edgeFade={0.3}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ const RESPONSE_LEVELS = [
|
|||||||
'n/a'
|
'n/a'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Akquise-Tickets: Ergebnis des Kundenkontakts (statt technischer Status)
|
||||||
|
const ACQUISITION_STATUS = ['In Kontakt', 'Zugesagt', 'Abgesagt']
|
||||||
|
|
||||||
export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate }) {
|
export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate }) {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const today = new Date().toLocaleDateString('de-DE', {
|
const today = new Date().toLocaleDateString('de-DE', {
|
||||||
@@ -55,12 +58,18 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [autoCalculate, setAutoCalculate] = useState(true)
|
const [autoCalculate, setAutoCalculate] = useState(true)
|
||||||
|
|
||||||
|
const isAcquisition = workorder?.type === 'Akquise'
|
||||||
|
const statusOptions = isAcquisition ? ACQUISITION_STATUS : STATUS_OPTIONS
|
||||||
|
const defaultStatus = isAcquisition
|
||||||
|
? (ACQUISITION_STATUS.includes(workorder?.status) ? workorder.status : 'In Kontakt')
|
||||||
|
: (workorder?.status || 'Open')
|
||||||
|
|
||||||
// Reset form wenn Modal geöffnet wird
|
// Reset form wenn Modal geöffnet wird
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && workorder) {
|
if (isOpen && workorder) {
|
||||||
setFormData({
|
setFormData({
|
||||||
serviceType: 'Remote',
|
serviceType: 'Remote',
|
||||||
newStatus: workorder.status || 'Open',
|
newStatus: defaultStatus,
|
||||||
newResponseLevel: workorder.responseLevel || '',
|
newResponseLevel: workorder.responseLevel || '',
|
||||||
totalTime: 0,
|
totalTime: 0,
|
||||||
startDate: today,
|
startDate: today,
|
||||||
@@ -189,17 +198,22 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">New Status</label>
|
<label className="form-label">{isAcquisition ? 'Akquise-Ergebnis' : 'New Status'}</label>
|
||||||
<select
|
<select
|
||||||
className="form-control"
|
className="form-control"
|
||||||
value={formData.newStatus}
|
value={formData.newStatus}
|
||||||
onChange={(e) => handleChange('newStatus', e.target.value)}
|
onChange={(e) => handleChange('newStatus', e.target.value)}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
{STATUS_OPTIONS.map(status => (
|
{statusOptions.map(status => (
|
||||||
<option key={status} value={status}>{status}</option>
|
<option key={status} value={status}>{status}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{isAcquisition && (
|
||||||
|
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||||
|
"Zugesagt" macht den Lead automatisch zum festen Kunden.
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|||||||
158
src/components/DocumentsPanel.jsx
Normal file
158
src/components/DocumentsPanel.jsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { FaFileLines, FaDownload, FaEye, FaSpinner, FaRotate, FaUpload, FaMagnifyingGlass } from 'react-icons/fa6'
|
||||||
|
import { integrationsApi } from '../lib/integrationsApi'
|
||||||
|
import FileUploadModal from './FileUploadModal'
|
||||||
|
|
||||||
|
const card = {
|
||||||
|
background: 'rgba(45, 55, 72, 0.5)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentsPanel({ ticket, initialScope = 'ticket' }) {
|
||||||
|
const [docs, setDocs] = useState([])
|
||||||
|
const [count, setCount] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [scope, setScope] = useState(initialScope) // 'ticket' | 'customer'
|
||||||
|
const [showUpload, setShowUpload] = useState(false)
|
||||||
|
const [busyId, setBusyId] = useState(null)
|
||||||
|
|
||||||
|
const correspondent = ticket.customerName || ''
|
||||||
|
const woid = ticket.woid || ''
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const params = { page_size: 30 }
|
||||||
|
if (query) params.query = query
|
||||||
|
if (scope === 'customer' && correspondent) params.correspondent = correspondent
|
||||||
|
if (scope === 'ticket' && woid) params.tag = `WOID-${woid}`
|
||||||
|
const data = await integrationsApi.listDocuments(params)
|
||||||
|
setDocs(data.documents || [])
|
||||||
|
setCount(data.count || 0)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
setDocs([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [query, scope, correspondent, woid])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const open = async (id, kind) => {
|
||||||
|
setBusyId(id + kind)
|
||||||
|
try {
|
||||||
|
await integrationsApi.openDocument(id, kind)
|
||||||
|
} catch (err) {
|
||||||
|
alert('Dokument konnte nicht geoeffnet werden: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setBusyId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={card}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
<h5 style={{ color: 'var(--dark-text)', fontWeight: 'bold', margin: 0, fontSize: '16px' }}>
|
||||||
|
Dokumente (Paperless)
|
||||||
|
</h5>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button className="btn btn-green" style={{ margin: 0 }} onClick={() => setShowUpload(true)}>
|
||||||
|
<FaUpload /> Hochladen
|
||||||
|
</button>
|
||||||
|
<button className="btn" onClick={load} title="Aktualisieren"
|
||||||
|
style={{ background: '#374151', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}>
|
||||||
|
<FaRotate />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
|
<button
|
||||||
|
className={`btn ${scope === 'ticket' ? 'btn-green' : ''}`}
|
||||||
|
style={{ margin: 0, background: scope === 'ticket' ? undefined : '#374151', color: 'white' }}
|
||||||
|
onClick={() => setScope('ticket')}
|
||||||
|
>
|
||||||
|
Dieses Ticket
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn ${scope === 'customer' ? 'btn-green' : ''}`}
|
||||||
|
style={{ margin: 0, background: scope === 'customer' ? undefined : '#374151', color: 'white' }}
|
||||||
|
onClick={() => setScope('customer')}
|
||||||
|
disabled={!correspondent}
|
||||||
|
>
|
||||||
|
Ganzer Kunde
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '6px', flex: 1, minWidth: '180px' }}>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
placeholder="Volltextsuche..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && load()}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-green" style={{ margin: 0 }} onClick={load}>
|
||||||
|
<FaMagnifyingGlass />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p style={{ color: '#a0aec0' }}><FaSpinner className="spinner" /> Dokumente werden geladen...</p>
|
||||||
|
) : docs.length === 0 ? (
|
||||||
|
<p style={{ color: '#a0aec0', fontSize: '13px' }}>
|
||||||
|
Keine Dokumente gefunden{scope === 'ticket' ? ' fuer dieses Ticket' : correspondent ? ` fuer ${correspondent}` : ''}.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p style={{ color: '#718096', fontSize: '12px', marginTop: 0 }}>{count} Dokument(e)</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{docs.map((d) => (
|
||||||
|
<div key={d.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px', background: 'rgba(0,0,0,0.15)', borderRadius: '8px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0 }}>
|
||||||
|
<FaFileLines style={{ color: '#10b981', flexShrink: 0 }} />
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ color: 'var(--dark-text)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.title}</div>
|
||||||
|
<div style={{ color: '#718096', fontSize: '12px' }}>{d.created ? new Date(d.created).toLocaleDateString('de-DE') : ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
|
||||||
|
<button className="btn" title="Vorschau" disabled={busyId === d.id + 'preview'}
|
||||||
|
style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}
|
||||||
|
onClick={() => open(d.id, 'preview')}>
|
||||||
|
{busyId === d.id + 'preview' ? <FaSpinner className="spinner" /> : <FaEye />}
|
||||||
|
</button>
|
||||||
|
<button className="btn" title="Download" disabled={busyId === d.id + 'download'}
|
||||||
|
style={{ background: '#374151', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}
|
||||||
|
onClick={() => open(d.id, 'download')}>
|
||||||
|
{busyId === d.id + 'download' ? <FaSpinner className="spinner" /> : <FaDownload />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FileUploadModal
|
||||||
|
isOpen={showUpload}
|
||||||
|
onClose={() => setShowUpload(false)}
|
||||||
|
workorderId={woid}
|
||||||
|
ticket={ticket}
|
||||||
|
onUploadComplete={() => setTimeout(load, 1500)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
src/components/EditWorksheetTimeModal.jsx
Normal file
142
src/components/EditWorksheetTimeModal.jsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { FaClock } from 'react-icons/fa'
|
||||||
|
|
||||||
|
/** Minuten zwischen hhmm-Zeiten, Übernacht (+24h) wie useWorksheets.calculateTime. */
|
||||||
|
function calculateMinutes(startTime, endTime) {
|
||||||
|
if (!startTime || !endTime) return null
|
||||||
|
const sh = parseInt(startTime.substring(0, 2), 10)
|
||||||
|
const sm = parseInt(startTime.substring(2, 4), 10)
|
||||||
|
const eh = parseInt(endTime.substring(0, 2), 10)
|
||||||
|
const em = parseInt(endTime.substring(2, 4), 10)
|
||||||
|
if ([sh, sm, eh, em].some(Number.isNaN)) return null
|
||||||
|
let diff = eh * 60 + em - (sh * 60 + sm)
|
||||||
|
if (diff < 0) diff += 24 * 60
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMinutes(minutes) {
|
||||||
|
if (minutes === null || minutes === undefined) return '–'
|
||||||
|
const h = Math.floor(minutes / 60)
|
||||||
|
const m = minutes % 60
|
||||||
|
return h > 0 ? `${h}h ${m}min` : `${m}min`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startzeit fuer Git-Push-Worksheets nachtragen: Die Endzeit wurde beim Push
|
||||||
|
* automatisch gesetzt, hier wird der Arbeitsbeginn erfasst. Beim Speichern
|
||||||
|
* wird totalTime berechnet und isComment auf false gesetzt, damit der Eintrag
|
||||||
|
* in der Arbeitszeit-Statistik zaehlt.
|
||||||
|
*/
|
||||||
|
export default function EditWorksheetTimeModal({ isOpen, onClose, worksheet, onSave }) {
|
||||||
|
const [startDate, setStartDate] = useState('')
|
||||||
|
const [startTime, setStartTime] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && worksheet) {
|
||||||
|
setStartDate(worksheet.startDate || worksheet.endDate || '')
|
||||||
|
setStartTime(worksheet.startTime || '')
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
}, [isOpen, worksheet])
|
||||||
|
|
||||||
|
const totalTime = useMemo(
|
||||||
|
() => calculateMinutes(startTime, worksheet?.endTime),
|
||||||
|
[startTime, worksheet]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isOpen || !worksheet) return null
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!/^\d{2}\.\d{2}\.\d{4}$/.test(startDate)) {
|
||||||
|
setError('Startdatum bitte als TT.MM.JJJJ angeben.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/^\d{4}$/.test(startTime)) {
|
||||||
|
setError('Startzeit bitte als HHMM angeben (z.B. 0930).')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (totalTime === null) {
|
||||||
|
setError('Zeit konnte nicht berechnet werden.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
const result = await onSave(worksheet, {
|
||||||
|
startDate,
|
||||||
|
startTime,
|
||||||
|
totalTime,
|
||||||
|
isComment: false,
|
||||||
|
})
|
||||||
|
setSaving(false)
|
||||||
|
if (result?.success) {
|
||||||
|
onClose()
|
||||||
|
} else {
|
||||||
|
setError(result?.error || 'Speichern fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overlay">
|
||||||
|
<span className="overlay-close" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
<div className="overlay-content">
|
||||||
|
<h2 className="mb-2">
|
||||||
|
<FaClock style={{ marginRight: 8 }} />
|
||||||
|
Zeit nachtragen – WSID {worksheet.wsid}
|
||||||
|
</h2>
|
||||||
|
<p className="text-grey" style={{ fontSize: '14px' }}>
|
||||||
|
Die Endzeit wurde beim Git-Push automatisch gesetzt
|
||||||
|
({worksheet.endDate} {worksheet.endTime ? `${worksheet.endTime.substring(0, 2)}:${worksheet.endTime.substring(2, 4)}` : ''}).
|
||||||
|
Trage hier ein, wann die Arbeit begonnen hat.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Startdatum (TT.MM.JJJJ)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
placeholder="11.07.2026"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Startzeit (HHMM)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={startTime}
|
||||||
|
onChange={(e) => setStartTime(e.target.value.replace(/[^0-9]/g, '').slice(0, 4))}
|
||||||
|
placeholder="0930"
|
||||||
|
maxLength={4}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Berechnete Arbeitszeit</label>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: 600, color: '#10b981' }}>
|
||||||
|
{formatMinutes(totalTime)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center mt-2">
|
||||||
|
<button type="submit" className="btn btn-green" disabled={saving}>
|
||||||
|
{saving ? 'Speichert...' : 'Zeit speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState, useRef } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { FaTimes, FaSpinner } from 'react-icons/fa'
|
import { FaTimes, FaSpinner } from 'react-icons/fa'
|
||||||
import { storage, BUCKET_ID, ID } from '../lib/appwrite'
|
import { storage, BUCKET_ID, ID } from '../lib/appwrite'
|
||||||
|
import { integrationsApi } from '../lib/integrationsApi'
|
||||||
|
|
||||||
export default function FileUploadModal({ isOpen, onClose, workorderId, onUploadComplete }) {
|
export default function FileUploadModal({ isOpen, onClose, workorderId, ticket, onUploadComplete }) {
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [dragOver, setDragOver] = useState(false)
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
const handleDrop = async (e) => {
|
const handleDrop = async (e) => {
|
||||||
@@ -33,14 +35,39 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
|
|||||||
}
|
}
|
||||||
|
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
|
setStatus('Lade in Ablage hoch...')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await storage.createFile(
|
const response = await storage.createFile(
|
||||||
BUCKET_ID,
|
BUCKET_ID,
|
||||||
ID.unique(),
|
ID.unique(),
|
||||||
file
|
file
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Automatisch nach Paperless-ngx schieben (wichtige Kundendokumente)
|
||||||
|
const woid = ticket?.woid || workorderId
|
||||||
|
const correspondent = ticket?.customerName || undefined
|
||||||
|
if (correspondent || woid) {
|
||||||
|
setStatus('Synchronisiere mit Paperless...')
|
||||||
|
const tags = []
|
||||||
|
if (correspondent) tags.push(correspondent)
|
||||||
|
if (woid) tags.push(`WOID-${woid}`)
|
||||||
|
tags.push('Ticket')
|
||||||
|
try {
|
||||||
|
await integrationsApi.uploadToPaperless({
|
||||||
|
bucketId: BUCKET_ID,
|
||||||
|
fileId: response.$id,
|
||||||
|
filename: file.name,
|
||||||
|
title: `${woid ? `[${woid}] ` : ''}${file.name}`,
|
||||||
|
correspondent,
|
||||||
|
tags,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Paperless-Sync fehlgeschlagen:', e.message)
|
||||||
|
// Upload in Appwrite ist trotzdem erfolgt - nicht hart abbrechen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onUploadComplete?.(response)
|
onUploadComplete?.(response)
|
||||||
onClose()
|
onClose()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -48,6 +75,7 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
|
|||||||
alert('Error uploading file: ' + error.message)
|
alert('Error uploading file: ' + error.message)
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
|
setStatus('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +100,7 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
|
|||||||
{uploading ? (
|
{uploading ? (
|
||||||
<div>
|
<div>
|
||||||
<FaSpinner className="spinner" size={32} />
|
<FaSpinner className="spinner" size={32} />
|
||||||
<p className="mt-1">Uploading file...</p>
|
<p className="mt-1">{status || 'Uploading file...'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer className="footer">
|
<footer className="footer">
|
||||||
<p>
|
<p className="faint" style={{ fontSize: 13, margin: 0 }}>
|
||||||
WOMS 2.0 - Work Order Management System |
|
Webklar Ticket-Plattform · © {new Date().getFullYear()}
|
||||||
Powered by <a href="https://appwrite.io" target="_blank" rel="noopener noreferrer">Appwrite</a> & React
|
|
||||||
</p>
|
|
||||||
<p className="text-small text-grey">
|
|
||||||
© {new Date().getFullYear()} - All rights reserved
|
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
|
|||||||
30
src/components/GiteaLinkButton.jsx
Normal file
30
src/components/GiteaLinkButton.jsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { SiGitea } from 'react-icons/si'
|
||||||
|
|
||||||
|
export default function GiteaLinkButton({ href, title = 'In Gitea oeffnen', size = 16 }) {
|
||||||
|
if (!href) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'rgba(99, 102, 241, 0.15)',
|
||||||
|
border: '1px solid rgba(99, 102, 241, 0.35)',
|
||||||
|
color: '#818cf8',
|
||||||
|
textDecoration: 'none',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SiGitea size={size} />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
343
src/components/InvoicePanel.jsx
Normal file
343
src/components/InvoicePanel.jsx
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { FaFilePdf, FaPlus, FaLink, FaSpinner, FaRotate } from 'react-icons/fa6'
|
||||||
|
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
|
||||||
|
import { integrationsApi, formatMoney, INVOICE_STATUS } from '../lib/integrationsApi'
|
||||||
|
import { useInvoices } from '../hooks/useInvoices'
|
||||||
|
import { customerStage, isInvoiceReady } from '../lib/leads'
|
||||||
|
|
||||||
|
const card = {
|
||||||
|
background: 'rgba(45, 55, 72, 0.5)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InvoicePanel({ ticket }) {
|
||||||
|
const [customer, setCustomer] = useState(null)
|
||||||
|
const [customerLoading, setCustomerLoading] = useState(true)
|
||||||
|
const [customerError, setCustomerError] = useState(null)
|
||||||
|
|
||||||
|
const loadCustomer = useCallback(async () => {
|
||||||
|
if (!ticket.customerId) {
|
||||||
|
setCustomer(null)
|
||||||
|
setCustomerLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCustomerLoading(true)
|
||||||
|
try {
|
||||||
|
const doc = await databases.getDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, ticket.customerId)
|
||||||
|
setCustomer(doc)
|
||||||
|
setCustomerError(null)
|
||||||
|
} catch (err) {
|
||||||
|
setCustomerError(err.message)
|
||||||
|
setCustomer(null)
|
||||||
|
} finally {
|
||||||
|
setCustomerLoading(false)
|
||||||
|
}
|
||||||
|
}, [ticket.customerId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCustomer()
|
||||||
|
}, [loadCustomer])
|
||||||
|
|
||||||
|
const clientId = customer?.invoiceNinjaClientId || null
|
||||||
|
const { invoices, loading, error, refresh, createInvoice } = useInvoices(clientId)
|
||||||
|
|
||||||
|
if (customerLoading) {
|
||||||
|
return (
|
||||||
|
<div style={card}>
|
||||||
|
<FaSpinner className="spinner" /> Kunde wird geladen...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ticket.customerId) {
|
||||||
|
return (
|
||||||
|
<div style={card}>
|
||||||
|
<p style={{ color: '#a0aec0', margin: 0 }}>Kein Kunde am Ticket verknuepft - keine Rechnungen verfuegbar.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={card}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||||
|
<h5 style={{ color: 'var(--dark-text)', fontWeight: 'bold', margin: 0, fontSize: '16px' }}>
|
||||||
|
Rechnungen {customer?.name ? `- ${customer.name}` : ''}
|
||||||
|
</h5>
|
||||||
|
{clientId && (
|
||||||
|
<button className="btn" onClick={refresh} title="Aktualisieren"
|
||||||
|
style={{ background: '#374151', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}>
|
||||||
|
<FaRotate />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customerError && <div className="text-red" style={{ marginBottom: 8 }}>{customerError}</div>}
|
||||||
|
|
||||||
|
{customer && customerStage(customer) === 'lead' ? (
|
||||||
|
<p style={{ color: '#a0aec0', margin: 0 }}>
|
||||||
|
Dieser Kunde ist noch ein Lead. Erst zum festen Kunden machen (im Kunden-Bereich, vollstaendige Daten noetig) - dann sind Rechnungen moeglich.
|
||||||
|
</p>
|
||||||
|
) : !clientId && !isInvoiceReady(customer) ? (
|
||||||
|
<p style={{ color: '#a0aec0', margin: 0 }}>
|
||||||
|
Rechnungsdaten unvollstaendig. Bitte im Kunden-Bereich Unternehmen, E-Mail, Strasse, PLZ und Stadt ergaenzen.
|
||||||
|
</p>
|
||||||
|
) : !clientId ? (
|
||||||
|
<LinkClient customer={customer} onLinked={loadCustomer} />
|
||||||
|
) : (
|
||||||
|
<InvoiceList
|
||||||
|
ticket={ticket}
|
||||||
|
clientId={clientId}
|
||||||
|
invoices={invoices}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
createInvoice={createInvoice}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------- Verknuepfung -----------
|
||||||
|
function LinkClient({ customer, onLinked }) {
|
||||||
|
const [search, setSearch] = useState(customer?.name || '')
|
||||||
|
const [results, setResults] = useState([])
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const doSearch = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await integrationsApi.listClients(search)
|
||||||
|
setResults(data.clients || [])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
doSearch()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const saveMapping = async (clientId) => {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
|
||||||
|
invoiceNinjaClientId: clientId,
|
||||||
|
})
|
||||||
|
onLinked()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAndLink = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await integrationsApi.createClient({
|
||||||
|
name: customer.name || customer.companyName || 'Neuer Kunde',
|
||||||
|
email: customer.email || '',
|
||||||
|
phone: customer.phone || '',
|
||||||
|
address: customer.location || '',
|
||||||
|
})
|
||||||
|
if (data.client?.id) {
|
||||||
|
await saveMapping(data.client.id)
|
||||||
|
} else {
|
||||||
|
throw new Error('Client konnte nicht angelegt werden')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p style={{ color: '#a0aec0', fontSize: '13px', marginTop: 0 }}>
|
||||||
|
Dieser Kunde ist noch nicht mit InvoiceNinja verknuepft.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
placeholder="InvoiceNinja-Kunde suchen..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && doSearch()}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-green" style={{ margin: 0 }} onClick={doSearch} disabled={busy}>
|
||||||
|
{busy ? <FaSpinner className="spinner" /> : 'Suchen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||||
|
|
||||||
|
<div style={{ maxHeight: '180px', overflowY: 'auto', marginBottom: '10px' }}>
|
||||||
|
{results.map((c) => (
|
||||||
|
<div key={c.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
<div>
|
||||||
|
<strong style={{ color: 'var(--dark-text)' }}>{c.name}</strong>
|
||||||
|
{c.number ? <span style={{ color: '#a0aec0' }}> <EFBFBD> {c.number}</span> : null}
|
||||||
|
{c.email ? <div style={{ color: '#a0aec0', fontSize: '12px' }}>{c.email}</div> : null}
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-green" style={{ margin: 0 }} disabled={saving} onClick={() => saveMapping(c.id)}>
|
||||||
|
<FaLink /> Verknuepfen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!busy && results.length === 0 && (
|
||||||
|
<p style={{ color: '#a0aec0', fontSize: '13px' }}>Keine Treffer.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn" style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '10px 16px', borderRadius: '8px', cursor: 'pointer' }} disabled={saving} onClick={createAndLink}>
|
||||||
|
{saving ? <FaSpinner className="spinner" /> : <><FaPlus /> Neuen InvoiceNinja-Kunden anlegen & verknuepfen</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------- Rechnungsliste ---------
|
||||||
|
function InvoiceList({ ticket, clientId, invoices, loading, error, createInvoice }) {
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [openingId, setOpeningId] = useState(null)
|
||||||
|
|
||||||
|
const openPdf = async (id) => {
|
||||||
|
setOpeningId(id)
|
||||||
|
try {
|
||||||
|
await integrationsApi.openInvoicePdf(id)
|
||||||
|
} catch (err) {
|
||||||
|
alert('PDF konnte nicht geoeffnet werden: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setOpeningId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button className="btn btn-green" style={{ marginBottom: '12px' }} onClick={() => setShowCreate((s) => !s)}>
|
||||||
|
<FaPlus /> Rechnung aus Ticket erstellen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<CreateInvoiceForm
|
||||||
|
ticket={ticket}
|
||||||
|
clientId={clientId}
|
||||||
|
onCancel={() => setShowCreate(false)}
|
||||||
|
onSubmit={async (payload) => {
|
||||||
|
const r = await createInvoice(payload)
|
||||||
|
if (r.success) setShowCreate(false)
|
||||||
|
return r
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p style={{ color: '#a0aec0' }}><FaSpinner className="spinner" /> Rechnungen werden geladen...</p>
|
||||||
|
) : invoices.length === 0 ? (
|
||||||
|
<p style={{ color: '#a0aec0', fontSize: '13px' }}>Noch keine Rechnungen fuer diesen Kunden.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table className="table" style={{ width: '100%', fontSize: '13px' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ color: '#a0aec0', textAlign: 'left' }}>
|
||||||
|
<th>Nr.</th><th>Datum</th><th>Faellig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invoices.map((inv) => {
|
||||||
|
const st = INVOICE_STATUS[inv.status_id] || { label: '<27>', color: '#a0aec0' }
|
||||||
|
const overdue = Number(inv.balance) > 0 && inv.due_date && new Date(inv.due_date) < new Date()
|
||||||
|
return (
|
||||||
|
<tr key={inv.id}>
|
||||||
|
<td style={{ color: 'var(--dark-text)' }}>{inv.number || inv.id?.slice(-6)}</td>
|
||||||
|
<td style={{ color: '#cbd5e0' }}>{inv.date || '-'}</td>
|
||||||
|
<td style={{ color: overdue ? '#ef4444' : '#cbd5e0' }}>{inv.due_date || '-'}</td>
|
||||||
|
<td style={{ color: 'var(--dark-text)' }}>{formatMoney(inv.amount)}</td>
|
||||||
|
<td style={{ color: Number(inv.balance) > 0 ? '#f59e0b' : '#10b981' }}>{formatMoney(inv.balance)}</td>
|
||||||
|
<td><span style={{ color: st.color, fontWeight: 'bold' }}>{st.label}{overdue ? ' (ueberfaellig)' : ''}</span></td>
|
||||||
|
<td>
|
||||||
|
<button className="btn" title="PDF oeffnen" disabled={openingId === inv.id}
|
||||||
|
style={{ background: '#ef4444', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}
|
||||||
|
onClick={() => openPdf(inv.id)}>
|
||||||
|
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------- Rechnung anlegen -------
|
||||||
|
function CreateInvoiceForm({ ticket, clientId, onCancel, onSubmit }) {
|
||||||
|
const [description, setDescription] = useState(ticket.topic || ticket.title || '')
|
||||||
|
const [cost, setCost] = useState('')
|
||||||
|
const [quantity, setQuantity] = useState('1')
|
||||||
|
const [markSent, setMarkSent] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
const r = await onSubmit({
|
||||||
|
client_id: clientId,
|
||||||
|
po_number: ticket.woid || '',
|
||||||
|
public_notes: `Ticket ${ticket.woid || ''}: ${ticket.topic || ''}`.trim(),
|
||||||
|
line_items: [{ notes: description, cost: Number(cost || 0), quantity: Number(quantity || 1) }],
|
||||||
|
markSent,
|
||||||
|
})
|
||||||
|
if (!r.success) {
|
||||||
|
setError(r.error || 'Fehler beim Anlegen')
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} style={{ ...card, marginBottom: '12px' }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Leistung / Beschreibung</label>
|
||||||
|
<textarea className="form-control" rows={2} value={description} onChange={(e) => setDescription(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label className="form-label">Einzelpreis (EUR)</label>
|
||||||
|
<input type="number" step="0.01" className="form-control" value={cost} onChange={(e) => setCost(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label className="form-label">Menge</label>
|
||||||
|
<input type="number" step="1" className="form-control" value={quantity} onChange={(e) => setQuantity(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#cbd5e0', marginBottom: '12px' }}>
|
||||||
|
<input type="checkbox" checked={markSent} onChange={(e) => setMarkSent(e.target.checked)} />
|
||||||
|
Direkt als "versendet" markieren
|
||||||
|
</label>
|
||||||
|
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button type="submit" className="btn btn-green" disabled={busy}>
|
||||||
|
{busy ? <FaSpinner className="spinner" /> : 'Rechnung erstellen'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn" style={{ background: '#374151', color: 'white' }} onClick={onCancel}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
IconUsers,
|
||||||
IconDeviceDesktop,
|
IconTargetArrow,
|
||||||
IconChartBar,
|
IconReportMoney,
|
||||||
IconCalendar,
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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, FaClockRotateLeft } from 'react-icons/fa6'
|
import { FaLock, FaLockOpen, FaPlay, FaStop, FaTruck, FaSackDollar, FaUserGear, FaPlus, FaClockRotateLeft, FaPen } from 'react-icons/fa6'
|
||||||
import { formatDistanceToNow, format } from 'date-fns'
|
import { formatDistanceToNow, format } from 'date-fns'
|
||||||
import { de } from 'date-fns/locale'
|
import { de } from 'date-fns/locale'
|
||||||
import StatusDropdown from './StatusDropdown'
|
import StatusDropdown from './StatusDropdown'
|
||||||
@@ -7,9 +7,14 @@ import PriorityDropdown from './PriorityDropdown'
|
|||||||
import EditorDropdown from './EditorDropdown'
|
import EditorDropdown from './EditorDropdown'
|
||||||
import ResponseDropdown from './ResponseDropdown'
|
import ResponseDropdown from './ResponseDropdown'
|
||||||
import CreateWorksheetModal from './CreateWorksheetModal'
|
import CreateWorksheetModal from './CreateWorksheetModal'
|
||||||
|
import EditWorksheetTimeModal from './EditWorksheetTimeModal'
|
||||||
import StatusHistoryModal from './StatusHistoryModal'
|
import StatusHistoryModal from './StatusHistoryModal'
|
||||||
import WorksheetList from './WorksheetList'
|
import WorksheetList from './WorksheetList'
|
||||||
import WorksheetStats from './WorksheetStats'
|
import WorksheetStats from './WorksheetStats'
|
||||||
|
import TicketAssignedProjectsPanel from './TicketAssignedProjectsPanel'
|
||||||
|
import TicketProjectsModal from './TicketProjectsModal'
|
||||||
|
import InvoicePanel from './InvoicePanel'
|
||||||
|
import DocumentsPanel from './DocumentsPanel'
|
||||||
import { useWorksheets } from '../hooks/useWorksheets'
|
import { useWorksheets } from '../hooks/useWorksheets'
|
||||||
|
|
||||||
const PRIORITY_CLASSES = {
|
const PRIORITY_CLASSES = {
|
||||||
@@ -20,14 +25,6 @@ const PRIORITY_CLASSES = {
|
|||||||
4: 'priority-critical'
|
4: 'priority-critical'
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRIORITY_LABELS = {
|
|
||||||
0: 'NONE',
|
|
||||||
1: 'LOW',
|
|
||||||
2: 'MEDIUM',
|
|
||||||
3: 'HIGH',
|
|
||||||
4: 'CRITICAL'
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_CLASSES = {
|
const STATUS_CLASSES = {
|
||||||
'Open': 'status-open',
|
'Open': 'status-open',
|
||||||
'Occupied': 'status-occupied',
|
'Occupied': 'status-occupied',
|
||||||
@@ -43,38 +40,42 @@ const APPROVAL_ICONS = {
|
|||||||
'shipping': FaTruck
|
'shipping': FaTruck
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'details', label: 'Details' },
|
||||||
|
{ id: 'worksheets', label: 'Arbeitsblaetter' },
|
||||||
|
{ id: 'invoices', label: 'Rechnungen' },
|
||||||
|
{ id: 'documents', label: 'Dokumente' },
|
||||||
|
{ id: 'project', label: 'Projekt / Preview' },
|
||||||
|
]
|
||||||
|
|
||||||
export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const [locked, setLocked] = useState(true)
|
const [locked, setLocked] = useState(true)
|
||||||
|
const [activeTab, setActiveTab] = useState('details')
|
||||||
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
|
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
|
||||||
|
const [editTimeWorksheet, setEditTimeWorksheet] = useState(null)
|
||||||
const [showHistoryModal, setShowHistoryModal] = useState(false)
|
const [showHistoryModal, setShowHistoryModal] = useState(false)
|
||||||
|
const [showProjectsModal, setShowProjectsModal] = useState(false)
|
||||||
// Worksheets für dieses Ticket laden (nur wenn expanded)
|
const [projectsRefreshKey, setProjectsRefreshKey] = useState(0)
|
||||||
const {
|
|
||||||
worksheets,
|
const {
|
||||||
loading: worksheetsLoading,
|
worksheets,
|
||||||
|
loading: worksheetsLoading,
|
||||||
createWorksheet,
|
createWorksheet,
|
||||||
getTotalTime
|
updateWorksheet,
|
||||||
|
getTotalTime
|
||||||
} = useWorksheets(expanded ? ticket.woid : null)
|
} = useWorksheets(expanded ? ticket.woid : null)
|
||||||
|
|
||||||
|
// Startzeit fuer Git-Push-Worksheets nachtragen (Endzeit kam vom Push)
|
||||||
|
const handleSaveWorksheetTime = async (ws, data) => updateWorksheet(ws.$id, data)
|
||||||
|
|
||||||
const createdAt = new Date(ticket.$createdAt || ticket.createdAt)
|
const createdAt = new Date(ticket.$createdAt || ticket.createdAt)
|
||||||
const elapsed = formatDistanceToNow(createdAt, { locale: de })
|
const elapsed = formatDistanceToNow(createdAt, { locale: de })
|
||||||
|
|
||||||
const handleStatusChange = (newStatus) => {
|
|
||||||
onUpdate(ticket.$id, { status: newStatus })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePriorityChange = (newPriority) => {
|
const handleStatusChange = (newStatus) => onUpdate(ticket.$id, { status: newStatus })
|
||||||
onUpdate(ticket.$id, { priority: newPriority })
|
const handlePriorityChange = (newPriority) => onUpdate(ticket.$id, { priority: newPriority })
|
||||||
}
|
const handleEditorChange = (newEditor) => onUpdate(ticket.$id, { assignedTo: newEditor })
|
||||||
|
const handleResponseChange = (newResponse) => onUpdate(ticket.$id, { responseLevel: newResponse })
|
||||||
const handleEditorChange = (newEditor) => {
|
|
||||||
onUpdate(ticket.$id, { assignedTo: newEditor })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResponseChange = (newResponse) => {
|
|
||||||
onUpdate(ticket.$id, { responseLevel: newResponse })
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleLock = () => {
|
const toggleLock = () => {
|
||||||
if (locked) {
|
if (locked) {
|
||||||
@@ -88,15 +89,12 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
|||||||
|
|
||||||
const handleCreateWorksheet = async (worksheetData, currentUser) => {
|
const handleCreateWorksheet = async (worksheetData, currentUser) => {
|
||||||
const result = await createWorksheet(worksheetData, currentUser)
|
const result = await createWorksheet(worksheetData, currentUser)
|
||||||
|
|
||||||
// Wenn Status geändert wurde, aktualisiere Work Order
|
|
||||||
if (result.success && worksheetData.newStatus !== ticket.status) {
|
if (result.success && worksheetData.newStatus !== ticket.status) {
|
||||||
await onUpdate(ticket.$id, {
|
await onUpdate(ticket.$id, {
|
||||||
status: worksheetData.newStatus,
|
status: worksheetData.newStatus,
|
||||||
responseLevel: worksheetData.newResponseLevel || ticket.responseLevel
|
responseLevel: worksheetData.newResponseLevel || ticket.responseLevel
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,25 +134,19 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
|||||||
{ticket.topic}
|
{ticket.topic}
|
||||||
</td>
|
</td>
|
||||||
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
|
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
|
||||||
<StatusDropdown
|
<StatusDropdown value={ticket.status} onChange={handleStatusChange} />
|
||||||
value={ticket.status}
|
|
||||||
onChange={handleStatusChange}
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
|
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
|
||||||
<PriorityDropdown
|
<PriorityDropdown value={ticket.priority} onChange={handlePriorityChange} />
|
||||||
value={ticket.priority}
|
|
||||||
onChange={handlePriorityChange}
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className={`text-center ${ticket.approvalStatus === 'approved' ? 'bg-green' : 'bg-yellow'}`}
|
className={`text-center ${ticket.approvalStatus === 'approved' ? 'bg-green' : 'bg-yellow'}`}
|
||||||
rowSpan={2}
|
rowSpan={2}
|
||||||
style={{ verticalAlign: 'middle' }}
|
style={{ verticalAlign: 'middle' }}
|
||||||
>
|
>
|
||||||
<ApprovalIcon size={24} />
|
<ApprovalIcon size={24} />
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className="bg-dark-grey text-center text-white"
|
className="bg-dark-grey text-center text-white"
|
||||||
rowSpan={2}
|
rowSpan={2}
|
||||||
style={{ verticalAlign: 'middle', cursor: 'pointer' }}
|
style={{ verticalAlign: 'middle', cursor: 'pointer' }}
|
||||||
@@ -165,132 +157,79 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr className="ticket-row">
|
<tr className="ticket-row">
|
||||||
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
|
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
|
||||||
<EditorDropdown
|
<EditorDropdown value={ticket.assignedTo} onChange={handleEditorChange} />
|
||||||
value={ticket.assignedTo}
|
|
||||||
onChange={handleEditorChange}
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
|
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
|
||||||
<ResponseDropdown
|
<ResponseDropdown value={ticket.responseLevel} onChange={handleResponseChange} />
|
||||||
value={ticket.responseLevel}
|
|
||||||
onChange={handleResponseChange}
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<>
|
<tr className="worksheet-expansion">
|
||||||
<tr className="worksheet-expansion">
|
<td colSpan={10} className="worksheet-cell">
|
||||||
<td colSpan={10} className="worksheet-cell">
|
<div className="card" style={{
|
||||||
<div className="card" style={{
|
borderRadius: '0 0 12px 12px',
|
||||||
borderRadius: '0 0 12px 12px',
|
marginTop: 0,
|
||||||
marginTop: 0,
|
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
borderTop: 'none'
|
||||||
borderTop: 'none'
|
}}>
|
||||||
}}>
|
<div className="card-body" style={{ borderRadius: '0 0 12px 12px', padding: '20px' }}>
|
||||||
<div className="card-body" style={{ borderRadius: '0 0 12px 12px', padding: '20px' }}>
|
|
||||||
{/* Bento Box Layout: 2 Spalten */}
|
{/* Tab-Leiste */}
|
||||||
<div style={{
|
<div className="ticket-tabs">
|
||||||
display: 'grid',
|
{TABS.map((t) => (
|
||||||
gridTemplateColumns: '1fr 1fr',
|
<button
|
||||||
gap: '20px',
|
key={t.id}
|
||||||
alignItems: 'stretch'
|
type="button"
|
||||||
}}>
|
className={`ticket-tab ${activeTab === t.id ? 'ticket-tab-active' : ''}`}
|
||||||
{/* Linke Spalte: Ticket-Beschreibung (50%) */}
|
onClick={() => setActiveTab(t.id)}
|
||||||
<div style={{
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ticket-tab-content">
|
||||||
|
{activeTab === 'details' && (
|
||||||
|
<div style={{
|
||||||
background: 'rgba(45, 55, 72, 0.5)',
|
background: 'rgba(45, 55, 72, 0.5)',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
height: '100%'
|
|
||||||
}}>
|
}}>
|
||||||
<h5 style={{
|
<h5 style={{ color: 'var(--dark-text)', fontWeight: 'bold', marginBottom: '16px', fontSize: '18px' }}>
|
||||||
color: 'var(--dark-text)',
|
Ticket-Beschreibung
|
||||||
fontWeight: 'bold',
|
|
||||||
marginBottom: '16px',
|
|
||||||
fontSize: '18px',
|
|
||||||
flex: '0 0 auto'
|
|
||||||
}}>
|
|
||||||
📋 Ticket-Beschreibung
|
|
||||||
</h5>
|
</h5>
|
||||||
<p style={{
|
<p style={{ fontSize: '14px', lineHeight: '1.8', color: 'rgba(226, 232, 240, 0.85)', whiteSpace: 'pre-wrap', margin: 0 }}>
|
||||||
fontSize: '14px',
|
|
||||||
lineHeight: '1.8',
|
|
||||||
color: 'rgba(226, 232, 240, 0.8)',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
margin: 0,
|
|
||||||
flex: '1 1 auto',
|
|
||||||
overflowY: 'auto'
|
|
||||||
}}>
|
|
||||||
{ticket.details || 'Keine Details vorhanden.'}
|
{ticket.details || 'Keine Details vorhanden.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Rechte Spalte: Statistics, Buttons (50%) */}
|
{activeTab === 'worksheets' && (
|
||||||
<div style={{
|
<div>
|
||||||
display: 'flex',
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'stretch', marginBottom: '16px' }}>
|
||||||
flexDirection: 'column',
|
<button
|
||||||
gap: '16px',
|
|
||||||
height: '100%'
|
|
||||||
}}>
|
|
||||||
{/* Button Row: Add Worksheet (100%) + History Icon Button */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '8px',
|
|
||||||
alignItems: 'stretch'
|
|
||||||
}}>
|
|
||||||
{/* Add Worksheet Button - 100% width minus icon button */}
|
|
||||||
<button
|
|
||||||
className="btn"
|
className="btn"
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||||
color: 'white',
|
color: 'white', border: 'none', padding: '12px 20px', borderRadius: '8px',
|
||||||
border: 'none',
|
fontWeight: 'bold', cursor: 'pointer', flex: 1
|
||||||
padding: '12px 20px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
flex: 1
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
|
||||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.4)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'translateY(0)'
|
|
||||||
e.currentTarget.style.boxShadow = 'none'
|
|
||||||
}}
|
}}
|
||||||
onClick={() => setShowCreateWorksheet(true)}
|
onClick={() => setShowCreateWorksheet(true)}
|
||||||
>
|
>
|
||||||
<FaPlus style={{ marginRight: '8px' }} /> Add Worksheet
|
<FaPlus style={{ marginRight: '8px' }} /> Add Worksheet
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* History Icon Button - klein, grau, nur Icon */}
|
|
||||||
<button
|
<button
|
||||||
style={{
|
type="button"
|
||||||
background: '#616161',
|
style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '12px', borderRadius: '8px', cursor: 'pointer', minWidth: '44px' }}
|
||||||
color: 'white',
|
onClick={() => setShowProjectsModal(true)}
|
||||||
border: 'none',
|
title="Projekte bearbeiten"
|
||||||
padding: '12px',
|
>
|
||||||
borderRadius: '8px',
|
<FaPen size={18} />
|
||||||
cursor: 'pointer',
|
</button>
|
||||||
transition: 'all 0.2s ease',
|
<button
|
||||||
display: 'flex',
|
type="button"
|
||||||
alignItems: 'center',
|
style={{ background: '#616161', color: 'white', border: 'none', padding: '12px', borderRadius: '8px', cursor: 'pointer', minWidth: '44px' }}
|
||||||
justifyContent: 'center',
|
|
||||||
minWidth: '44px',
|
|
||||||
width: '44px'
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = '#757575'
|
|
||||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = '#616161'
|
|
||||||
e.currentTarget.style.transform = 'translateY(0)'
|
|
||||||
}}
|
|
||||||
onClick={() => setShowHistoryModal(true)}
|
onClick={() => setShowHistoryModal(true)}
|
||||||
title="Status History"
|
title="Status History"
|
||||||
>
|
>
|
||||||
@@ -298,55 +237,67 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Statistiken */}
|
|
||||||
{worksheets.length > 0 && (
|
{worksheets.length > 0 && (
|
||||||
<div style={{
|
<div style={{ background: 'rgba(45, 55, 72, 0.5)', borderRadius: '12px', padding: '16px', border: '1px solid rgba(16, 185, 129, 0.2)', marginBottom: '16px' }}>
|
||||||
background: 'rgba(45, 55, 72, 0.5)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
padding: '16px',
|
|
||||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
|
||||||
flex: '1 1 auto',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
minHeight: 0
|
|
||||||
}}>
|
|
||||||
<WorksheetStats worksheets={worksheets} />
|
<WorksheetStats worksheets={worksheets} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gesamtarbeitszeit und Worksheet-Liste - 100% Breite unter dem Bento Box */}
|
<WorksheetList worksheets={worksheets} totalTime={getTotalTime()} loading={worksheetsLoading} onEditTime={setEditTimeWorksheet} />
|
||||||
<div style={{
|
</div>
|
||||||
marginTop: '20px',
|
)}
|
||||||
width: '100%'
|
|
||||||
}}>
|
{activeTab === 'invoices' && <InvoicePanel ticket={ticket} />}
|
||||||
<WorksheetList
|
|
||||||
worksheets={worksheets}
|
{activeTab === 'documents' && <DocumentsPanel ticket={ticket} />}
|
||||||
totalTime={getTotalTime()}
|
|
||||||
loading={worksheetsLoading}
|
{activeTab === 'project' && (
|
||||||
/>
|
<div>
|
||||||
</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
|
||||||
isOpen={showCreateWorksheet}
|
isOpen={showCreateWorksheet}
|
||||||
onClose={() => setShowCreateWorksheet(false)}
|
onClose={() => setShowCreateWorksheet(false)}
|
||||||
workorder={ticket}
|
workorder={ticket}
|
||||||
onCreate={handleCreateWorksheet}
|
onCreate={handleCreateWorksheet}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EditWorksheetTimeModal
|
||||||
|
isOpen={Boolean(editTimeWorksheet)}
|
||||||
|
onClose={() => setEditTimeWorksheet(null)}
|
||||||
|
worksheet={editTimeWorksheet}
|
||||||
|
onSave={handleSaveWorksheetTime}
|
||||||
|
/>
|
||||||
|
|
||||||
<StatusHistoryModal
|
<StatusHistoryModal
|
||||||
isOpen={showHistoryModal}
|
isOpen={showHistoryModal}
|
||||||
onClose={() => setShowHistoryModal(false)}
|
onClose={() => setShowHistoryModal(false)}
|
||||||
worksheets={worksheets}
|
worksheets={worksheets}
|
||||||
ticket={ticket}
|
ticket={ticket}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TicketProjectsModal
|
||||||
|
isOpen={showProjectsModal}
|
||||||
|
onClose={() => setShowProjectsModal(false)}
|
||||||
|
ticket={ticket}
|
||||||
|
onUpdated={() => setProjectsRefreshKey((k) => k + 1)}
|
||||||
|
/>
|
||||||
<tr className="spacer">
|
<tr className="spacer">
|
||||||
<td colSpan={10} style={{ height: '12px', background: 'transparent', border: 'none' }}></td>
|
<td colSpan={10} style={{ height: '12px', background: 'transparent', border: 'none' }}></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
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,7 +1,11 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp } from 'react-icons/fa'
|
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp } from 'react-icons/fa'
|
||||||
|
|
||||||
export default function WorksheetList({ worksheets, totalTime, loading }) {
|
function needsTimeEntry(ws) {
|
||||||
|
return ws.serviceType === 'GIT' && (!ws.startTime || !Number(ws.totalTime))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorksheetList({ worksheets, totalTime, loading, onEditTime }) {
|
||||||
const [expandedWorksheets, setExpandedWorksheets] = useState({})
|
const [expandedWorksheets, setExpandedWorksheets] = useState({})
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -49,13 +53,14 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
|
|||||||
<div className="timeline">
|
<div className="timeline">
|
||||||
{worksheets.map((ws, index) => {
|
{worksheets.map((ws, index) => {
|
||||||
const isExpanded = expandedWorksheets[ws.wsid] || false
|
const isExpanded = expandedWorksheets[ws.wsid] || false
|
||||||
|
const isGit = ws.serviceType === 'GIT'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={ws.$id} className="timeline-item mb-4" style={{
|
<div key={ws.$id} className="timeline-item mb-4" style={{
|
||||||
animation: `fadeIn 0.5s ease-in-out ${index * 0.1}s backwards`
|
animation: `fadeIn 0.5s ease-in-out ${index * 0.1}s backwards`
|
||||||
}}>
|
}}>
|
||||||
<div className="card border-0 shadow-sm overflow-hidden" style={{
|
<div className="card border-0 shadow-sm overflow-hidden" style={{
|
||||||
borderLeft: ws.isComment ? '4px solid #10b981' : '4px solid #4a5568',
|
borderLeft: isGit ? '4px solid #f97316' : ws.isComment ? '4px solid #10b981' : '4px solid #4a5568',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
||||||
}} onMouseEnter={(e) => {
|
}} onMouseEnter={(e) => {
|
||||||
@@ -70,7 +75,9 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
|
|||||||
className="card-header d-flex justify-content-between align-items-center py-3"
|
className="card-header d-flex justify-content-between align-items-center py-3"
|
||||||
onClick={() => toggleWorksheet(ws.wsid)}
|
onClick={() => toggleWorksheet(ws.wsid)}
|
||||||
style={{
|
style={{
|
||||||
background: ws.isComment
|
background: isGit
|
||||||
|
? 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)'
|
||||||
|
: ws.isComment
|
||||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||||
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
@@ -80,12 +87,16 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
|
|||||||
transition: 'background 0.2s ease'
|
transition: 'background 0.2s ease'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = ws.isComment
|
e.currentTarget.style.background = isGit
|
||||||
|
? 'linear-gradient(135deg, #ea580c 0%, #c2410c 100%)'
|
||||||
|
: ws.isComment
|
||||||
? 'linear-gradient(135deg, #059669 0%, #047857 100%)'
|
? 'linear-gradient(135deg, #059669 0%, #047857 100%)'
|
||||||
: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)'
|
: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)'
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.background = ws.isComment
|
e.currentTarget.style.background = isGit
|
||||||
|
? 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)'
|
||||||
|
: ws.isComment
|
||||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||||
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)'
|
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)'
|
||||||
}}
|
}}
|
||||||
@@ -98,13 +109,20 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<strong className="fs-6">WSID {ws.wsid}</strong>
|
<strong className="fs-6">WSID {ws.wsid}</strong>
|
||||||
{ws.isComment && (
|
{ws.isComment && !isGit && (
|
||||||
<span className="badge ms-2" style={{
|
<span className="badge ms-2" style={{
|
||||||
background: 'rgba(255,255,255,0.3)'
|
background: 'rgba(255,255,255,0.3)'
|
||||||
}}>
|
}}>
|
||||||
<FaComment className="me-1" /> Kommentar
|
<FaComment className="me-1" /> Kommentar
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{needsTimeEntry(ws) && (
|
||||||
|
<span className="badge ms-2" style={{
|
||||||
|
background: 'rgba(255,255,255,0.3)'
|
||||||
|
}}>
|
||||||
|
<FaClock className="me-1" /> Zeit offen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Collapsed: Mitarbeiter & Zeit im Header */}
|
{/* Collapsed: Mitarbeiter & Zeit im Header */}
|
||||||
{!isExpanded && (
|
{!isExpanded && (
|
||||||
@@ -126,9 +144,31 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<small style={{ opacity: 0.9 }}>
|
<div className="d-flex align-items-center gap-2">
|
||||||
{formatDateTime(ws.startDate, ws.startTime)}
|
{needsTimeEntry(ws) && onEditTime && (
|
||||||
</small>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,255,255,0.25)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.4)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '2px 10px',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onEditTime(ws)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaClock className="me-1" /> Zeit nachtragen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<small style={{ opacity: 0.9 }}>
|
||||||
|
{formatDateTime(ws.startDate, ws.startTime) || formatDateTime(ws.endDate, ws.endTime)}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body - Nur wenn expanded */}
|
{/* Body - Nur wenn expanded */}
|
||||||
|
|||||||
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))
|
||||||
@@ -59,18 +119,24 @@ export function AuthProvider({ children }) {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasAppwriteSession()) {
|
||||||
|
setUser(null)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await account.get()
|
const session = await account.get()
|
||||||
setUser(session)
|
setUser(session)
|
||||||
// Automatisch zur employees Collection hinzufügen
|
|
||||||
await ensureEmployeeExists(session)
|
await ensureEmployeeExists(session)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Kein Fehler loggen beim initialen Check - das ist normal wenn nicht eingeloggt
|
|
||||||
// Nur loggen wenn es ein unerwarteter Fehler ist (nicht 401)
|
|
||||||
if (error.code !== 401 && error.code !== 404) {
|
if (error.code !== 401 && error.code !== 404) {
|
||||||
console.error('Unexpected error checking user:', error)
|
console.error('Unexpected error checking user:', error)
|
||||||
}
|
}
|
||||||
|
if (error.code === 401) {
|
||||||
|
clearAllLocalAppwriteSessions()
|
||||||
|
}
|
||||||
setUser(null)
|
setUser(null)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -79,45 +145,45 @@ export function AuthProvider({ children }) {
|
|||||||
|
|
||||||
async function login(email, password) {
|
async function login(email, password) {
|
||||||
if (DEMO_MODE) {
|
if (DEMO_MODE) {
|
||||||
// Demo login - accept any credentials
|
|
||||||
const demoUser = { $id: 'demo', email, name: email.split('@')[0] }
|
const demoUser = { $id: 'demo', email, name: email.split('@')[0] }
|
||||||
localStorage.setItem('demo_user', JSON.stringify(demoUser))
|
localStorage.setItem('demo_user', JSON.stringify(demoUser))
|
||||||
setUser(demoUser)
|
setUser(demoUser)
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Appwrite 1.5.7 / SDK 13.0 - versuche beide Methoden für Kompatibilität
|
const normalizedEmail = email.trim()
|
||||||
try {
|
const normalizedPassword = password.trim()
|
||||||
await account.createEmailSession(email, password)
|
clearStaleAppwriteSessions()
|
||||||
} catch (e) {
|
|
||||||
// Fallback für ältere API
|
await createSessionWithCleanup(normalizedEmail, normalizedPassword)
|
||||||
if (account.createEmailPasswordSession) {
|
|
||||||
await account.createEmailPasswordSession(email, password)
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// User-Daten laden und automatisch zur employees Collection hinzufügen
|
|
||||||
const session = await account.get()
|
const session = await account.get()
|
||||||
setUser(session)
|
setUser(session)
|
||||||
await ensureEmployeeExists(session)
|
await ensureEmployeeExists(session)
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login error:', error)
|
console.error('Login error:', error)
|
||||||
let errorMessage = error.message || 'Login fehlgeschlagen'
|
let errorMessage = error.message || 'Login fehlgeschlagen'
|
||||||
|
|
||||||
// Bessere Fehlermeldungen
|
if (error.code === 429 || errorMessage.includes('Rate limit')) {
|
||||||
if (error.code === 401 || errorMessage.includes('Invalid credentials')) {
|
errorMessage = 'Zu viele Login-Versuche. Bitte 15–30 Minuten warten und es erneut versuchen.'
|
||||||
|
} else if (errorMessage.includes('missing scopes') && errorMessage.includes('account')) {
|
||||||
|
errorMessage = 'Session konnte nicht gespeichert werden. Bitte Seite neu laden und erneut versuchen.'
|
||||||
|
} else if (
|
||||||
|
error?.type === 'user_session_already_exists' ||
|
||||||
|
errorMessage.includes('session is active')
|
||||||
|
) {
|
||||||
|
errorMessage = 'Es gibt noch eine alte Sitzung. Bitte Seite neu laden und erneut anmelden.'
|
||||||
|
} else if (error.code === 401 || errorMessage.includes('Invalid credentials')) {
|
||||||
errorMessage = 'Ungültige Email oder Passwort'
|
errorMessage = 'Ungültige Email oder Passwort'
|
||||||
} else if (errorMessage.includes('User not found')) {
|
} else if (errorMessage.includes('User not found')) {
|
||||||
errorMessage = 'Benutzer nicht gefunden. Bitte registriere dich zuerst.'
|
errorMessage = 'Benutzer nicht gefunden. Bitte registriere dich zuerst.'
|
||||||
} else if (errorMessage.includes('Email/Password')) {
|
} else if (errorMessage.includes('Email/Password')) {
|
||||||
errorMessage = 'Email/Password Authentifizierung ist nicht aktiviert. Bitte aktiviere sie in deinem Appwrite Dashboard unter Auth → Providers.'
|
errorMessage = 'Email/Password Authentifizierung ist nicht aktiviert. Bitte aktiviere sie in deinem Appwrite Dashboard unter Auth → Providers.'
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: false, error: errorMessage }
|
return { success: false, error: errorMessage }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,28 +194,25 @@ export function AuthProvider({ children }) {
|
|||||||
setUser(null)
|
setUser(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await account.deleteSession('current')
|
await account.deleteSessions()
|
||||||
setUser(null)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout error:', error)
|
console.error('Logout error:', error)
|
||||||
|
} finally {
|
||||||
|
clearAllLocalAppwriteSessions()
|
||||||
|
setUser(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hilfsfunktion um zu prüfen ob Benutzer Admin ist
|
const isAdmin = Boolean(user?.labels?.includes('admin'))
|
||||||
const isAdmin = () => {
|
|
||||||
if (!user) return false
|
|
||||||
// Prüfe ob Benutzer das "admin" Label hat
|
|
||||||
return user.labels?.includes('admin') || false
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
user,
|
user,
|
||||||
loading,
|
loading,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
isAdmin: isAdmin()
|
isAdmin
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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,7 +1,19 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from '../lib/appwrite'
|
import { databases, DATABASE_ID, COLLECTIONS, Query, ID, isDemoMode } from '../lib/appwrite'
|
||||||
|
|
||||||
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
|
const DEMO_MODE = isDemoMode
|
||||||
|
|
||||||
|
function buildFiltersKey(filters = {}) {
|
||||||
|
return JSON.stringify({
|
||||||
|
limit: filters.limit ?? null,
|
||||||
|
customerId: filters.customerId ?? null,
|
||||||
|
assignedTo: filters.assignedTo ?? null,
|
||||||
|
status: filters.status ?? null,
|
||||||
|
type: filters.type ?? null,
|
||||||
|
priority: filters.priority ?? null,
|
||||||
|
woid: filters.woid ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Demo data for testing without Appwrite
|
// Demo data for testing without Appwrite
|
||||||
const lastWeek = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
const lastWeek = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||||
@@ -69,21 +81,34 @@ export function useWorkorders(filters = {}) {
|
|||||||
const [workorders, setWorkorders] = useState([])
|
const [workorders, setWorkorders] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const filtersKey = useMemo(() => buildFiltersKey(filters), [
|
||||||
|
filters.limit,
|
||||||
|
filters.customerId,
|
||||||
|
filters.assignedTo,
|
||||||
|
filters.status,
|
||||||
|
filters.type,
|
||||||
|
filters.priority,
|
||||||
|
filters.woid,
|
||||||
|
])
|
||||||
|
|
||||||
const fetchWorkorders = useCallback(async () => {
|
const fetchWorkorders = useCallback(async () => {
|
||||||
|
const activeFilters = JSON.parse(filtersKey)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
if (DEMO_MODE) {
|
if (DEMO_MODE) {
|
||||||
// Filter demo data
|
// Filter demo data
|
||||||
let filtered = [...DEMO_WORKORDERS]
|
let filtered = [...DEMO_WORKORDERS]
|
||||||
if (filters.status?.length > 0) {
|
if (activeFilters.status?.length > 0) {
|
||||||
filtered = filtered.filter(wo => filters.status.includes(wo.status))
|
filtered = filtered.filter(wo => activeFilters.status.includes(wo.status))
|
||||||
}
|
}
|
||||||
if (filters.priority?.length > 0) {
|
if (activeFilters.priority?.length > 0) {
|
||||||
filtered = filtered.filter(wo => filters.priority.includes(wo.priority))
|
filtered = filtered.filter(wo => activeFilters.priority.includes(wo.priority))
|
||||||
}
|
}
|
||||||
if (filters.limit) {
|
if (activeFilters.woid) {
|
||||||
filtered = filtered.slice(0, filters.limit)
|
filtered = filtered.filter(wo => String(wo.woid) === String(activeFilters.woid))
|
||||||
|
}
|
||||||
|
if (activeFilters.limit) {
|
||||||
|
filtered = filtered.slice(0, activeFilters.limit)
|
||||||
}
|
}
|
||||||
setWorkorders(filtered)
|
setWorkorders(filtered)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -93,39 +118,36 @@ export function useWorkorders(filters = {}) {
|
|||||||
try {
|
try {
|
||||||
const queries = [Query.orderDesc('$createdAt')]
|
const queries = [Query.orderDesc('$createdAt')]
|
||||||
|
|
||||||
if (filters.limit) {
|
if (activeFilters.limit) {
|
||||||
queries.push(Query.limit(filters.limit))
|
queries.push(Query.limit(activeFilters.limit))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Für Arrays: In Appwrite 1.5.7 gibt es kein Query.or()
|
// Appwrite Query.equal mit Array = OR-Match. Serverseitig filtern, damit
|
||||||
// Wir filtern clientseitig für mehrere Werte
|
// das Limit erst NACH dem Statusfilter greift (sonst wuerden archivierte
|
||||||
if (filters.status && filters.status.length > 0) {
|
// Tickets die neuesten Slots belegen und aktive verdraengen).
|
||||||
if (filters.status.length === 1) {
|
if (activeFilters.status && activeFilters.status.length > 0) {
|
||||||
queries.push(Query.equal('status', filters.status[0]))
|
queries.push(Query.equal('status', activeFilters.status))
|
||||||
}
|
}
|
||||||
// Für mehrere Werte: Clientseitig filtern (siehe unten)
|
|
||||||
|
if (activeFilters.type && activeFilters.type.length > 0) {
|
||||||
|
queries.push(Query.equal('type', activeFilters.type))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeFilters.priority && activeFilters.priority.length > 0) {
|
||||||
|
queries.push(Query.equal('priority', activeFilters.priority))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.type && filters.type.length > 0) {
|
if (activeFilters.customerId) {
|
||||||
if (filters.type.length === 1) {
|
queries.push(Query.equal('customerId', activeFilters.customerId))
|
||||||
queries.push(Query.equal('type', filters.type[0]))
|
|
||||||
}
|
|
||||||
// Für mehrere Werte: Clientseitig filtern
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.priority && filters.priority.length > 0) {
|
if (activeFilters.assignedTo) {
|
||||||
if (filters.priority.length === 1) {
|
queries.push(Query.equal('assignedTo', activeFilters.assignedTo))
|
||||||
queries.push(Query.equal('priority', filters.priority[0]))
|
|
||||||
}
|
|
||||||
// Für mehrere Werte: Clientseitig filtern
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.customerId) {
|
// WOID-Direktsuche (z. B. Deep-Link aus den Leads: /tickets?woid=123)
|
||||||
queries.push(Query.equal('customerId', filters.customerId))
|
if (activeFilters.woid) {
|
||||||
}
|
queries.push(Query.equal('woid', [String(activeFilters.woid)]))
|
||||||
|
|
||||||
if (filters.assignedTo) {
|
|
||||||
queries.push(Query.equal('assignedTo', filters.assignedTo))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug: Zeige Collection ID
|
// Debug: Zeige Collection ID
|
||||||
@@ -141,23 +163,8 @@ export function useWorkorders(filters = {}) {
|
|||||||
COLLECTIONS.WORKORDERS,
|
COLLECTIONS.WORKORDERS,
|
||||||
queries
|
queries
|
||||||
)
|
)
|
||||||
|
|
||||||
// Clientseitige Filterung für Arrays (da Query.or() nicht verfügbar ist)
|
setWorkorders(response.documents)
|
||||||
let filteredDocs = response.documents
|
|
||||||
|
|
||||||
if (filters.status && filters.status.length > 1) {
|
|
||||||
filteredDocs = filteredDocs.filter(doc => filters.status.includes(doc.status))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.type && filters.type.length > 1) {
|
|
||||||
filteredDocs = filteredDocs.filter(doc => filters.type.includes(doc.type))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.priority && filters.priority.length > 1) {
|
|
||||||
filteredDocs = filteredDocs.filter(doc => filters.priority.includes(doc.priority))
|
|
||||||
}
|
|
||||||
|
|
||||||
setWorkorders(filteredDocs)
|
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let errorMessage = err.message || 'Fehler beim Laden der Tickets'
|
let errorMessage = err.message || 'Fehler beim Laden der Tickets'
|
||||||
@@ -174,12 +181,60 @@ export function useWorkorders(filters = {}) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [filters])
|
}, [filtersKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchWorkorders()
|
fetchWorkorders()
|
||||||
}, [fetchWorkorders])
|
}, [fetchWorkorders])
|
||||||
|
|
||||||
|
const resolveAutomaticTicketFields = async (requestedAssignee = '') => {
|
||||||
|
const [allTicketsResponse, employeesResponse] = await Promise.all([
|
||||||
|
databases.listDocuments(
|
||||||
|
DATABASE_ID,
|
||||||
|
COLLECTIONS.WORKORDERS,
|
||||||
|
[Query.limit(5000)]
|
||||||
|
),
|
||||||
|
databases.listDocuments(
|
||||||
|
DATABASE_ID,
|
||||||
|
COLLECTIONS.EMPLOYEES,
|
||||||
|
[Query.orderAsc('displayName'), Query.limit(500)]
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
const allTickets = allTicketsResponse.documents || []
|
||||||
|
const employees = employeesResponse.documents || []
|
||||||
|
const maxWoid = allTickets.reduce((highest, ticket) => {
|
||||||
|
const numericId = Number.parseInt(ticket.woid, 10)
|
||||||
|
return Number.isFinite(numericId) ? Math.max(highest, numericId) : highest
|
||||||
|
}, 9999)
|
||||||
|
|
||||||
|
let assignee = requestedAssignee
|
||||||
|
? employees.find((employee) => employee.userId === requestedAssignee)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!assignee && employees.length > 0) {
|
||||||
|
const finishedStatuses = new Set(['Closed', 'Completed', 'Cancelled', 'Done'])
|
||||||
|
const activeCounts = new Map(employees.map((employee) => [employee.userId, 0]))
|
||||||
|
for (const ticket of allTickets) {
|
||||||
|
if (!ticket.assignedTo || finishedStatuses.has(ticket.status)) continue
|
||||||
|
if (activeCounts.has(ticket.assignedTo)) {
|
||||||
|
activeCounts.set(ticket.assignedTo, activeCounts.get(ticket.assignedTo) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assignee = [...employees].sort((left, right) => {
|
||||||
|
const loadDifference = activeCounts.get(left.userId) - activeCounts.get(right.userId)
|
||||||
|
if (loadDifference !== 0) return loadDifference
|
||||||
|
return (left.displayName || '').localeCompare(right.displayName || '', 'de')
|
||||||
|
})[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
woid: String(maxWoid + 1),
|
||||||
|
assignedTo: assignee?.userId || '',
|
||||||
|
assignedName: assignee?.displayName || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const createWorkorder = async (data) => {
|
const createWorkorder = async (data) => {
|
||||||
if (DEMO_MODE) {
|
if (DEMO_MODE) {
|
||||||
// Finde höchste WOID und +1
|
// Finde höchste WOID und +1
|
||||||
@@ -199,38 +254,16 @@ export function useWorkorders(filters = {}) {
|
|||||||
return { success: false, error: 'Das Feld "Topic" ist erforderlich.' }
|
return { success: false, error: 'Das Feld "Topic" ist erforderlich.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status-Automatik: Wenn Mitarbeiter zugewiesen → Status = "Assigned", sonst "Open"
|
const automatic = await resolveAutomaticTicketFields(data.assignedTo)
|
||||||
const autoStatus = (data.assignedTo && data.assignedTo !== '') ? 'Assigned' : 'Open'
|
const autoStatus = automatic.assignedTo ? 'Assigned' : 'Open'
|
||||||
|
|
||||||
// Generiere sequentielle 5-stellige WOID (wie im Original-System)
|
|
||||||
const generateWOID = () => {
|
|
||||||
// Finde die höchste bestehende WOID
|
|
||||||
if (workorders.length === 0) {
|
|
||||||
return '10000'; // Starte bei 10000 wenn keine Tickets existieren
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxWoid = Math.max(
|
|
||||||
...workorders
|
|
||||||
.map(wo => parseInt(wo.woid))
|
|
||||||
.filter(woid => !isNaN(woid) && woid > 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wenn keine gültige WOID gefunden wurde, starte bei 10000
|
|
||||||
if (maxWoid === -Infinity || isNaN(maxWoid)) {
|
|
||||||
return '10000';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gib die nächste Nummer zurück (sequentiell)
|
|
||||||
return (maxWoid + 1).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bereite Daten für Appwrite vor
|
// Bereite Daten für Appwrite vor
|
||||||
const workorderData = {
|
const workorderData = {
|
||||||
// Required fields
|
// Required fields
|
||||||
topic: data.topic.trim(),
|
topic: data.topic.trim(),
|
||||||
status: data.status || autoStatus, // Verwende übergebenen Status oder automatischen Status
|
status: autoStatus,
|
||||||
priority: typeof data.priority === 'number' ? data.priority : parseInt(data.priority) || 1,
|
priority: typeof data.priority === 'number' ? data.priority : parseInt(data.priority) || 1,
|
||||||
woid: generateWOID(), // 5-stellige Zahl
|
woid: automatic.woid,
|
||||||
|
|
||||||
// Optional fields - nur senden wenn vorhanden
|
// Optional fields - nur senden wenn vorhanden
|
||||||
type: data.type || '',
|
type: data.type || '',
|
||||||
@@ -238,7 +271,8 @@ export function useWorkorders(filters = {}) {
|
|||||||
responseLevel: data.responseLevel || '',
|
responseLevel: data.responseLevel || '',
|
||||||
serviceType: data.serviceType || 'Remote',
|
serviceType: data.serviceType || 'Remote',
|
||||||
customerId: data.customerId || '',
|
customerId: data.customerId || '',
|
||||||
assignedTo: data.assignedTo || '', // Zugewiesener Mitarbeiter
|
assignedTo: automatic.assignedTo,
|
||||||
|
assignedName: automatic.assignedName,
|
||||||
requestedBy: data.requestedBy || '',
|
requestedBy: data.requestedBy || '',
|
||||||
requestedFor: data.requestedFor || '',
|
requestedFor: data.requestedFor || '',
|
||||||
startDate: data.startDate || '',
|
startDate: data.startDate || '',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from '../lib/appwrite'
|
import { databases, DATABASE_ID, COLLECTIONS, Query, ID, isDemoMode } from '../lib/appwrite'
|
||||||
|
|
||||||
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
|
const DEMO_MODE = isDemoMode
|
||||||
|
|
||||||
// Demo data für Testing - Vollständiges Dummy-Ticket 10001 mit allen Worksheets
|
// Demo data für Testing - Vollständiges Dummy-Ticket 10001 mit allen Worksheets
|
||||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,72 +1,104 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||||
import { FaTicket, FaClipboardList, FaClock, FaCircleCheck } from 'react-icons/fa6'
|
import { FaTicket, FaClipboardList, FaClock, FaCircleCheck, FaPlus, FaFileInvoiceDollar, FaUpload } from 'react-icons/fa6'
|
||||||
|
import { integrationsApi, formatMoney } from '../lib/integrationsApi'
|
||||||
|
import { useFinanceSummary } from '../hooks/useFinanceSummary'
|
||||||
|
import { PageHeader, Card, Button, Kpi, EmptyState } from '../components/ui'
|
||||||
|
import { StatusPill, PriorityPill } from '../components/ui/StatusPill'
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [stats, setStats] = useState({
|
const { summary, loading: finLoading } = useFinanceSummary()
|
||||||
totalTickets: 0,
|
const [stats, setStats] = useState({ total: 0, open: 0, awaiting: 0, closed: 0 })
|
||||||
openTickets: 0,
|
const [recent, setRecent] = useState([])
|
||||||
closedTickets: 0,
|
const [docCount, setDocCount] = useState(null)
|
||||||
pendingTickets: 0
|
|
||||||
})
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchStats() {
|
let active = true
|
||||||
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const [total, open, closed, pending] = await Promise.all([
|
const [total, open, closed, awaiting, recentRes] = await Promise.all([
|
||||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.limit(1)]),
|
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.limit(1)]),
|
||||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Open'), Query.limit(1)]),
|
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Open'), Query.limit(1)]),
|
||||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Closed'), Query.limit(1)]),
|
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Closed'), Query.limit(1)]),
|
||||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Awaiting'), Query.limit(1)])
|
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Awaiting'), Query.limit(1)]),
|
||||||
|
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.orderDesc('$createdAt'), Query.limit(6)]),
|
||||||
])
|
])
|
||||||
|
if (!active) return
|
||||||
setStats({
|
setStats({ total: total.total, open: open.total, awaiting: awaiting.total, closed: closed.total })
|
||||||
totalTickets: total.total,
|
setRecent(recentRes.documents)
|
||||||
openTickets: open.total,
|
} catch (e) {
|
||||||
closedTickets: closed.total,
|
console.error(e)
|
||||||
pendingTickets: pending.total
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching stats:', err)
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
if (active) setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
try {
|
||||||
fetchStats()
|
const d = await integrationsApi.listDocuments({ page_size: 1 })
|
||||||
|
if (active) setDocCount(d.count)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
})()
|
||||||
|
return () => { active = false }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const StatCard = ({ icon: Icon, label, value, color }) => (
|
|
||||||
<div className="stat-card" style={{ borderLeft: `4px solid ${color}` }}>
|
|
||||||
<div className="stat-icon" style={{ color }}>
|
|
||||||
<Icon size={32} />
|
|
||||||
</div>
|
|
||||||
<div className="stat-info">
|
|
||||||
<span className="stat-value">{loading ? '...' : value}</span>
|
|
||||||
<span className="stat-label">{label}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="main-content">
|
<div className="page">
|
||||||
<header className="text-center mb-2">
|
<PageHeader
|
||||||
<h2>Dashboard</h2>
|
title="Dashboard"
|
||||||
</header>
|
subtitle="Ueberblick ueber Tickets, Finanzen und Dokumente"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" as={Link} to="/finance"><FaFileInvoiceDollar /> Finanzen</Button>
|
||||||
|
<Button variant="primary" as={Link} to="/tickets"><FaPlus /> Neues Ticket</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="stats-grid">
|
<div className="kpi-grid">
|
||||||
<StatCard icon={FaTicket} label="Total Tickets" value={stats.totalTickets} color="#2196F3" />
|
<Kpi label={<><FaTicket /> Tickets gesamt</>} value={loading ? '...' : stats.total} accent="var(--info)" />
|
||||||
<StatCard icon={FaClipboardList} label="Open Tickets" value={stats.openTickets} color="#4CAF50" />
|
<Kpi label={<><FaClipboardList /> Offen</>} value={loading ? '...' : stats.open} accent="var(--ok)" />
|
||||||
<StatCard icon={FaClock} label="Pending" value={stats.pendingTickets} color="#FF9800" />
|
<Kpi label={<><FaClock /> Wartend</>} value={loading ? '...' : stats.awaiting} accent="var(--warn)" />
|
||||||
<StatCard icon={FaCircleCheck} label="Closed" value={stats.closedTickets} color="#9E9E9E" />
|
<Kpi label={<><FaCircleCheck /> Geschlossen</>} value={loading ? '...' : stats.closed} accent="var(--text-faint)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dashboard-section mt-4">
|
<div className="kpi-grid">
|
||||||
<h3>Quick Actions</h3>
|
<Kpi label="Offener Betrag" value={finLoading ? '...' : formatMoney(summary?.outstanding)} sub={`${summary?.openCount ?? 0} offene Rechnungen`} accent="var(--warn)" />
|
||||||
<div className="quick-actions">
|
<Kpi label="Ueberfaellig" value={finLoading ? '...' : formatMoney(summary?.overdue)} sub={`${summary?.overdueCount ?? 0} ueberfaellig`} accent="var(--danger)" />
|
||||||
<a href="/tickets" className="btn btn-teal">View All Tickets</a>
|
<Kpi label="Bezahlt (Monat)" value={finLoading ? '...' : formatMoney(summary?.paidThisMonth)} accent="var(--ok)" />
|
||||||
<a href="/reports" className="btn btn-dark">Generate Report</a>
|
<Kpi label="Dokumente" value={docCount ?? '...'} accent="var(--info)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 16 }}>
|
||||||
|
<Card title="Letzte Tickets" pad={false}>
|
||||||
|
{loading ? (
|
||||||
|
<div className="empty">...</div>
|
||||||
|
) : recent.length === 0 ? (
|
||||||
|
<EmptyState title="Keine Tickets" />
|
||||||
|
) : (
|
||||||
|
<div className="list">
|
||||||
|
{recent.map((t) => (
|
||||||
|
<Link key={t.$id} to="/tickets" className="list-row">
|
||||||
|
<span className="mono" style={{ color: 'var(--accent)', minWidth: 56 }}>{t.woid || t.$id.slice(-5)}</span>
|
||||||
|
<div className="grow">
|
||||||
|
<div className="text-ellipsis" style={{ fontWeight: 600 }}>{t.topic || t.title || '-'}</div>
|
||||||
|
<div className="muted">{t.customerName || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<PriorityPill priority={t.priority} />
|
||||||
|
<StatusPill status={t.status} />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Schnellaktionen">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button as={Link} to="/tickets"><FaPlus /> Neues Ticket erstellen</Button>
|
||||||
|
<Button as={Link} to="/finance"><FaFileInvoiceDollar /> Rechnung erstellen</Button>
|
||||||
|
<Button as={Link} to="/documents"><FaUpload /> Dokument hochladen</Button>
|
||||||
|
<Button as={Link} to="/customers" variant="ghost">Kunden verwalten</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,66 +1,27 @@
|
|||||||
import { FaBook, FaCircleQuestion, FaKeyboard } from 'react-icons/fa6'
|
import { FaBook, FaCircleQuestion, FaKeyboard } from 'react-icons/fa6'
|
||||||
|
import { PageHeader, Card } from '../components/ui'
|
||||||
|
|
||||||
export default function DocsPage() {
|
export default function DocsPage() {
|
||||||
const sections = [
|
const sections = [
|
||||||
{
|
{ icon: FaBook, title: 'Erste Schritte', content: 'Tickets, Rechnungen und Dokumente in der Plattform verwalten.' },
|
||||||
icon: FaBook,
|
{ icon: FaCircleQuestion, title: 'FAQ', content: 'Haeufige Fragen zum System.' },
|
||||||
title: 'Getting Started',
|
{ icon: FaKeyboard, title: 'Tipps', content: 'Schneller arbeiten mit der Plattform.' },
|
||||||
content: 'Learn how to create and manage work orders in WOMS 2.0.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: FaCircleQuestion,
|
|
||||||
title: 'FAQ',
|
|
||||||
content: 'Frequently asked questions about the system.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: FaKeyboard,
|
|
||||||
title: 'Keyboard Shortcuts',
|
|
||||||
content: 'Speed up your workflow with keyboard shortcuts.'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const shortcuts = [
|
|
||||||
{ key: 'N', action: 'New Ticket' },
|
|
||||||
{ key: 'F', action: 'Focus Search' },
|
|
||||||
{ key: 'R', action: 'Refresh List' },
|
|
||||||
{ key: 'Esc', action: 'Close Modal' }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="main-content">
|
<div className="page">
|
||||||
<header className="text-center mb-2">
|
<PageHeader title="Dokumentation" subtitle="Hilfe und Anleitungen" />
|
||||||
<h2>Documentation</h2>
|
<div className="kpi-grid">
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="docs-grid">
|
|
||||||
{sections.map(({ icon: Icon, title, content }) => (
|
{sections.map(({ icon: Icon, title, content }) => (
|
||||||
<div key={title} className="doc-card">
|
<Card key={title}>
|
||||||
<Icon size={32} className="text-teal" />
|
<div className="flex items-center gap-3" style={{ marginBottom: 8 }}>
|
||||||
<h4>{title}</h4>
|
<Icon size={22} style={{ color: 'var(--accent)' }} />
|
||||||
<p className="text-grey">{content}</p>
|
<strong>{title}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="muted" style={{ margin: 0 }}>{content}</p>
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shortcuts-section mt-4">
|
|
||||||
<h3 className="text-center">Keyboard Shortcuts</h3>
|
|
||||||
<table className="table" style={{ maxWidth: '400px', margin: '0 auto' }}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Key</th>
|
|
||||||
<th>Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{shortcuts.map(({ key, action }) => (
|
|
||||||
<tr key={key}>
|
|
||||||
<td><kbd>{key}</kbd></td>
|
|
||||||
<td>{action}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
111
src/pages/DocumentsPage.jsx
Normal file
111
src/pages/DocumentsPage.jsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { FaFileLines, FaEye, FaDownload, FaSpinner, FaMagnifyingGlass, FaRotate, FaUpload } from 'react-icons/fa6'
|
||||||
|
import { integrationsApi } from '../lib/integrationsApi'
|
||||||
|
import { useCustomers } from '../hooks/useCustomers'
|
||||||
|
import { PageHeader, Card, Button, EmptyState } from '../components/ui'
|
||||||
|
import FileUploadModal from '../components/FileUploadModal'
|
||||||
|
|
||||||
|
export default function DocumentsPage() {
|
||||||
|
const { customers } = useCustomers()
|
||||||
|
const [docs, setDocs] = useState([])
|
||||||
|
const [count, setCount] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [correspondent, setCorrespondent] = useState('')
|
||||||
|
const [busyId, setBusyId] = useState(null)
|
||||||
|
const [showUpload, setShowUpload] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const params = { page_size: 50 }
|
||||||
|
if (query) params.query = query
|
||||||
|
if (correspondent) params.correspondent = correspondent
|
||||||
|
const data = await integrationsApi.listDocuments(params)
|
||||||
|
setDocs(data.documents || [])
|
||||||
|
setCount(data.count || 0)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
setDocs([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [query, correspondent])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const open = async (id, kind) => {
|
||||||
|
setBusyId(id + kind)
|
||||||
|
try { await integrationsApi.openDocument(id, kind) }
|
||||||
|
catch (err) { alert('Fehler: ' + err.message) }
|
||||||
|
finally { setBusyId(null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader
|
||||||
|
title="Dokumente"
|
||||||
|
subtitle="Alle Belege und Kundendokumente aus Paperless-ngx"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={load}><FaRotate /> Aktualisieren</Button>
|
||||||
|
<Button variant="primary" onClick={() => setShowUpload(true)}><FaUpload /> Hochladen</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card pad={false}>
|
||||||
|
<div className="ui-card-head" style={{ gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<select className="form-control" style={{ maxWidth: 240 }} value={correspondent} onChange={(e) => setCorrespondent(e.target.value)}>
|
||||||
|
<option value="">Alle Kunden</option>
|
||||||
|
{customers.map((c) => <option key={c.$id} value={c.name}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<div className="flex gap-2 grow" style={{ minWidth: 220 }}>
|
||||||
|
<input className="form-control" placeholder="Volltextsuche..." value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && load()} />
|
||||||
|
<Button variant="primary" onClick={load}><FaMagnifyingGlass /></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ui-card-body" style={{ padding: 0 }}>
|
||||||
|
{loading ? (
|
||||||
|
<div className="empty"><FaSpinner className="spinner" /></div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="empty text-red">{error}</div>
|
||||||
|
) : docs.length === 0 ? (
|
||||||
|
<EmptyState icon={<FaFileLines />} title="Keine Dokumente" hint="Keine Dokumente gefunden." />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="faint" style={{ padding: '8px 16px', fontSize: 12 }}>{count} Dokument(e)</div>
|
||||||
|
<div className="list">
|
||||||
|
{docs.map((d) => (
|
||||||
|
<div key={d.id} className="list-row">
|
||||||
|
<FaFileLines style={{ color: 'var(--accent)' }} />
|
||||||
|
<div className="grow">
|
||||||
|
<div className="text-ellipsis" style={{ fontWeight: 600 }}>{d.title}</div>
|
||||||
|
<div className="muted">{d.created ? new Date(d.created).toLocaleDateString('de-DE') : ''}</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="ghost" disabled={busyId === d.id + 'preview'} onClick={() => open(d.id, 'preview')} title="Vorschau">
|
||||||
|
{busyId === d.id + 'preview' ? <FaSpinner className="spinner" /> : <FaEye />}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" disabled={busyId === d.id + 'download'} onClick={() => open(d.id, 'download')} title="Download">
|
||||||
|
{busyId === d.id + 'download' ? <FaSpinner className="spinner" /> : <FaDownload />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<FileUploadModal
|
||||||
|
isOpen={showUpload}
|
||||||
|
onClose={() => setShowUpload(false)}
|
||||||
|
workorderId=""
|
||||||
|
ticket={correspondent ? { customerName: correspondent, woid: '' } : { customerName: '', woid: '' }}
|
||||||
|
onUploadComplete={() => setTimeout(load, 1500)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
189
src/pages/FinancePage.jsx
Normal file
189
src/pages/FinancePage.jsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { FaFilePdf, FaArrowUpRightFromSquare, FaSpinner, FaRotate, FaPlus } from 'react-icons/fa6'
|
||||||
|
import { integrationsApi, formatMoney, INVOICE_STATUS, INVOICE_NINJA_PUBLIC_URL } from '../lib/integrationsApi'
|
||||||
|
import { useFinanceSummary } from '../hooks/useFinanceSummary'
|
||||||
|
import { useCustomers } from '../hooks/useCustomers'
|
||||||
|
import { PageHeader, Card, Button, Kpi, EmptyState, Field } from '../components/ui'
|
||||||
|
|
||||||
|
export default function FinancePage() {
|
||||||
|
const { summary, loading: sumLoading, refresh: refreshSummary } = useFinanceSummary()
|
||||||
|
const { customers } = useCustomers()
|
||||||
|
const [invoices, setInvoices] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [filter, setFilter] = useState('all') // all | open | overdue | paid
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [openingId, setOpeningId] = useState(null)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await integrationsApi.listInvoices(undefined, 400)
|
||||||
|
setInvoices(data.invoices || [])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return invoices.filter((inv) => {
|
||||||
|
const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
|
||||||
|
if (filter === 'open' && !(Number(inv.balance) > 0)) return false
|
||||||
|
if (filter === 'overdue' && !overdue) return false
|
||||||
|
if (filter === 'paid' && inv.status_id !== 4) return false
|
||||||
|
if (search) {
|
||||||
|
const s = search.toLowerCase()
|
||||||
|
if (!`${inv.number} ${inv.client_name}`.toLowerCase().includes(s)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [invoices, filter, search, today])
|
||||||
|
|
||||||
|
const openPdf = async (id) => {
|
||||||
|
setOpeningId(id)
|
||||||
|
try { await integrationsApi.openInvoicePdf(id) }
|
||||||
|
catch (err) { alert('PDF-Fehler: ' + err.message) }
|
||||||
|
finally { setOpeningId(null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader
|
||||||
|
title="Finanzen"
|
||||||
|
subtitle="Rechnungen aus InvoiceNinja - Uebersicht, Status und schnelle Aktionen"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" as="a" href={INVOICE_NINJA_PUBLIC_URL} target="_blank" rel="noopener noreferrer">
|
||||||
|
<FaArrowUpRightFromSquare /> InvoiceNinja
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => { load(); refreshSummary() }}><FaRotate /> Aktualisieren</Button>
|
||||||
|
<Button variant="primary" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Rechnung erstellen</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="kpi-grid">
|
||||||
|
<Kpi label="Offener Betrag" value={sumLoading ? '...' : formatMoney(summary?.outstanding)} sub={`${summary?.openCount ?? 0} offene Rechnungen`} accent="var(--warn)" />
|
||||||
|
<Kpi label="Ueberfaellig" value={sumLoading ? '...' : formatMoney(summary?.overdue)} sub={`${summary?.overdueCount ?? 0} ueberfaellig`} accent="var(--danger)" />
|
||||||
|
<Kpi label="Bezahlt (Monat)" value={sumLoading ? '...' : formatMoney(summary?.paidThisMonth)} accent="var(--ok)" />
|
||||||
|
<Kpi label="Rechnungen gesamt" value={sumLoading ? '...' : (summary?.total ?? 0)} accent="var(--info)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<CreateInvoiceForm customers={customers} onDone={() => { setShowCreate(false); load(); refreshSummary() }} onCancel={() => setShowCreate(false)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card pad={false}>
|
||||||
|
<div className="ui-card-head">
|
||||||
|
<div className="seg">
|
||||||
|
{[['all', 'Alle'], ['open', 'Offen'], ['overdue', 'Ueberfaellig'], ['paid', 'Bezahlt']].map(([id, label]) => (
|
||||||
|
<button key={id} className={filter === id ? 'active' : ''} onClick={() => setFilter(id)}>{label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input className="form-control search-input" placeholder="Nummer oder Kunde suchen..." value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 280 }} />
|
||||||
|
</div>
|
||||||
|
<div className="ui-card-body" style={{ padding: 0 }}>
|
||||||
|
{loading ? (
|
||||||
|
<div className="empty"><FaSpinner className="spinner" /></div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="empty text-red">{error}</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<EmptyState title="Keine Rechnungen" hint="Keine Rechnungen fuer diesen Filter." />
|
||||||
|
) : (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table className="ui-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Nr.</th><th>Kunde</th><th>Datum</th><th>Faellig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((inv) => {
|
||||||
|
const st = INVOICE_STATUS[inv.status_id] || { label: '<27>', color: 'var(--text-muted)' }
|
||||||
|
const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
|
||||||
|
return (
|
||||||
|
<tr key={inv.id}>
|
||||||
|
<td className="mono">{inv.number || inv.id?.slice(-6)}</td>
|
||||||
|
<td>{inv.client_name || '-'}</td>
|
||||||
|
<td className="muted">{inv.date || '-'}</td>
|
||||||
|
<td style={{ color: overdue ? 'var(--danger)' : 'var(--text-muted)' }}>{inv.due_date || '-'}</td>
|
||||||
|
<td>{formatMoney(inv.amount)}</td>
|
||||||
|
<td style={{ color: Number(inv.balance) > 0 ? 'var(--warn)' : 'var(--ok)' }}>{formatMoney(inv.balance)}</td>
|
||||||
|
<td><span style={{ color: st.color, fontWeight: 700 }}>{st.label}{overdue ? ' (ueberf.)' : ''}</span></td>
|
||||||
|
<td>
|
||||||
|
<Button size="sm" variant="ghost" disabled={openingId === inv.id} onClick={() => openPdf(inv.id)} title="PDF">
|
||||||
|
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateInvoiceForm({ customers, onDone, onCancel }) {
|
||||||
|
const linkable = (customers || []).filter((c) => c.invoiceNinjaClientId)
|
||||||
|
const [clientId, setClientId] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [cost, setCost] = useState('')
|
||||||
|
const [quantity, setQuantity] = useState('1')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!clientId) { setError('Bitte Kunde waehlen'); return }
|
||||||
|
setBusy(true); setError(null)
|
||||||
|
try {
|
||||||
|
await integrationsApi.createInvoice({
|
||||||
|
client_id: clientId,
|
||||||
|
public_notes: description,
|
||||||
|
line_items: [{ notes: description, cost: Number(cost || 0), quantity: Number(quantity || 1) }],
|
||||||
|
})
|
||||||
|
onDone()
|
||||||
|
} catch (err) { setError(err.message); setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="Neue Rechnung">
|
||||||
|
{linkable.length === 0 ? (
|
||||||
|
<p className="muted">Kein Kunde ist mit InvoiceNinja verknuepft. Bitte zuerst im Kunden-Bereich verknuepfen.</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<Field label="Kunde">
|
||||||
|
<select className="form-control" value={clientId} onChange={(e) => setClientId(e.target.value)} required>
|
||||||
|
<option value="">Kunde waehlen...</option>
|
||||||
|
{linkable.map((c) => <option key={c.$id} value={c.invoiceNinjaClientId}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Leistung / Beschreibung">
|
||||||
|
<textarea className="form-control" rows={2} value={description} onChange={(e) => setDescription(e.target.value)} required />
|
||||||
|
</Field>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Field label="Einzelpreis (EUR)"><input className="form-control" type="number" step="0.01" value={cost} onChange={(e) => setCost(e.target.value)} required /></Field>
|
||||||
|
<Field label="Menge"><input className="form-control" type="number" step="1" value={quantity} onChange={(e) => setQuantity(e.target.value)} /></Field>
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="primary" type="submit" disabled={busy}>{busy ? <FaSpinner className="spinner" /> : 'Rechnung erstellen'}</Button>
|
||||||
|
<Button variant="ghost" type="button" onClick={onCancel}>Abbrechen</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
640
src/pages/LeadsPage.jsx
Normal file
640
src/pages/LeadsPage.jsx
Normal file
@@ -0,0 +1,640 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
FaSpinner,
|
||||||
|
FaStar,
|
||||||
|
FaGear,
|
||||||
|
FaPlus,
|
||||||
|
FaXmark,
|
||||||
|
FaMapLocationDot,
|
||||||
|
FaGlobe,
|
||||||
|
FaPhone,
|
||||||
|
FaEnvelope,
|
||||||
|
FaChevronDown,
|
||||||
|
FaChevronUp,
|
||||||
|
FaGitAlt,
|
||||||
|
FaFloppyDisk,
|
||||||
|
FaArrowsRotate,
|
||||||
|
FaCheck,
|
||||||
|
FaCopy,
|
||||||
|
FaEye,
|
||||||
|
FaHashtag,
|
||||||
|
FaTriangleExclamation,
|
||||||
|
} from 'react-icons/fa6'
|
||||||
|
import { useLeads, useLeadSettings, useLeadCycle } from '../hooks/useLeads'
|
||||||
|
import { PageHeader, Card, Button, Badge, EmptyState, Field, Kpi } from '../components/ui'
|
||||||
|
import CopyableCredential from '../components/CopyableCredential'
|
||||||
|
|
||||||
|
// Schritte der Server-Pipeline (processor.py) — Reihenfolge = pipelineStep 1..5
|
||||||
|
const PIPELINE_STEPS = [
|
||||||
|
'Lead erstellt',
|
||||||
|
'Kunde & Repo',
|
||||||
|
'Daten eingesetzt',
|
||||||
|
'Personalisiert',
|
||||||
|
'E-Mail erstellt',
|
||||||
|
]
|
||||||
|
|
||||||
|
const pulseStyle = `
|
||||||
|
@keyframes leadPulse { 0% { opacity: .35 } 50% { opacity: 1 } 100% { opacity: .35 } }
|
||||||
|
`
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ id: 'neu', label: 'Neu', tone: 'info' },
|
||||||
|
{ id: 'kontaktiert', label: 'Kontaktiert', tone: 'warn' },
|
||||||
|
{ id: 'interessiert', label: 'Interessiert', tone: 'warn' },
|
||||||
|
{ id: 'gewonnen', label: 'Gewonnen', tone: 'ok' },
|
||||||
|
{ id: 'abgelehnt', label: 'Abgelehnt', tone: 'danger' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusMeta = (id) => STATUS_OPTIONS.find((s) => s.id === id) || STATUS_OPTIONS[0]
|
||||||
|
|
||||||
|
function scoreColor(score) {
|
||||||
|
if (score >= 85) return '#34d399'
|
||||||
|
if (score >= 70) return '#fbbf24'
|
||||||
|
return '#f87171'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Balken ganz oben: laeuft von Minute 0 bis 5 (lastRunAt -> nextRunAt) und
|
||||||
|
* zeigt, wann der Server das naechste Mal nach neuen Leads schaut. Rechts
|
||||||
|
* ein Refresh-Icon, das den Lauf sofort ausloest (leadSettings.runNow). */
|
||||||
|
function CycleBar() {
|
||||||
|
const { cycle, triggering, triggerNow } = useLeadCycle()
|
||||||
|
const [now, setNow] = useState(Date.now())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const last = cycle?.lastRunAt ? new Date(cycle.lastRunAt).getTime() : null
|
||||||
|
const next = cycle?.nextRunAt ? new Date(cycle.nextRunAt).getTime() : null
|
||||||
|
const active = Boolean(cycle?.workerBusy || cycle?.runNow || triggering)
|
||||||
|
|
||||||
|
let pct = 0
|
||||||
|
let label = 'Automatik-Status unbekannt'
|
||||||
|
if (active) {
|
||||||
|
pct = 100
|
||||||
|
label = 'Pipeline läuft — Leads werden verarbeitet…'
|
||||||
|
} else if (last && next && next > last) {
|
||||||
|
pct = Math.min(100, Math.max(0, ((now - last) / (next - last)) * 100))
|
||||||
|
const rest = Math.max(0, next - now)
|
||||||
|
const m = Math.floor(rest / 60000)
|
||||||
|
const s = Math.floor((rest % 60000) / 1000)
|
||||||
|
label = rest > 0
|
||||||
|
? `Nächste Prüfung auf neue Leads in ${m}:${String(s).padStart(2, '0')} Min.`
|
||||||
|
: 'Prüfung startet gleich…'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ui-card pad" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<div className="grow" style={{ minWidth: 0 }}>
|
||||||
|
<div className="flex" style={{ justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13 }}>Lead-Automatik (alle 5 Minuten)</div>
|
||||||
|
<div className="muted" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>{label}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 8, borderRadius: 4, background: 'var(--surface-2)', overflow: 'hidden', marginTop: 6 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${pct}%`,
|
||||||
|
height: '100%',
|
||||||
|
background: active ? '#3b82f6' : 'var(--accent, #3b82f6)',
|
||||||
|
transition: 'width 1s linear',
|
||||||
|
animation: active ? 'leadPulse 1.2s ease-in-out infinite' : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={triggerNow}
|
||||||
|
disabled={active}
|
||||||
|
title="Jetzt sofort auf neue Leads prüfen und verarbeiten"
|
||||||
|
aria-label="Pipeline jetzt starten"
|
||||||
|
>
|
||||||
|
<FaArrowsRotate className={active ? 'spinner' : ''} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LeadsPage() {
|
||||||
|
const { leads, pipelines, loading, error } = useLeads()
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [status, setStatus] = useState('all')
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return (leads || [])
|
||||||
|
.filter((l) => (status === 'all' ? true : (l.crmStatus || 'neu') === status))
|
||||||
|
.filter((l) => {
|
||||||
|
if (!search) return true
|
||||||
|
const s = search.toLowerCase()
|
||||||
|
return `${l.firma} ${l.ort || ''} ${l.email || ''} ${l.branche || ''}`.toLowerCase().includes(s)
|
||||||
|
})
|
||||||
|
}, [leads, search, status])
|
||||||
|
|
||||||
|
const kpis = useMemo(() => {
|
||||||
|
const all = leads || []
|
||||||
|
const neu = all.filter((l) => (l.crmStatus || 'neu') === 'neu').length
|
||||||
|
const gewonnen = all.filter((l) => l.crmStatus === 'gewonnen').length
|
||||||
|
const avg = all.length
|
||||||
|
? Math.round(all.reduce((sum, l) => sum + (l.leadScore || 0), 0) / all.length)
|
||||||
|
: 0
|
||||||
|
const orte = new Set(all.map((l) => (l.ort || '').trim().toLowerCase()).filter(Boolean))
|
||||||
|
return { total: all.length, neu, gewonnen, avg, orte: orte.size }
|
||||||
|
}, [leads])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<style>{pulseStyle}</style>
|
||||||
|
<PageHeader
|
||||||
|
title="Leads"
|
||||||
|
subtitle="Recherche-Ergebnisse der täglichen 06:00-Routine — hier nur Anzeige & Verfolgung, gearbeitet wird im Akquise-Ticket"
|
||||||
|
actions={
|
||||||
|
<Button variant={showSettings ? 'primary' : 'default'} onClick={() => setShowSettings((s) => !s)}>
|
||||||
|
<FaGear /> Sucheinstellungen
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CycleBar />
|
||||||
|
|
||||||
|
{showSettings && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<LeadSettingsCard onClose={() => setShowSettings(false)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="kpi-grid" style={{ marginBottom: 16 }}>
|
||||||
|
<Kpi label="Leads gesamt" value={kpis.total} />
|
||||||
|
<Kpi label="Neu" value={kpis.neu} accent="#3b82f6" />
|
||||||
|
<Kpi label="Gewonnen" value={kpis.gewonnen} accent="#10b981" />
|
||||||
|
<Kpi label="Ø Lead-Score" value={kpis.avg} accent="#f59e0b" />
|
||||||
|
<Kpi label="Orte" value={kpis.orte} accent="#8b5cf6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card pad={false}>
|
||||||
|
<div className="ui-card-head" style={{ gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div className="seg">
|
||||||
|
<button className={status === 'all' ? 'active' : ''} onClick={() => setStatus('all')}>Alle</button>
|
||||||
|
{STATUS_OPTIONS.map((s) => (
|
||||||
|
<button key={s.id} className={status === s.id ? 'active' : ''} onClick={() => setStatus(s.id)}>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="form-control search-input"
|
||||||
|
placeholder="Lead suchen..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
style={{ maxWidth: 320 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ui-card-body">
|
||||||
|
{loading ? (
|
||||||
|
<div className="empty"><FaSpinner className="spinner" /></div>
|
||||||
|
) : error ? (
|
||||||
|
<EmptyState title="Fehler beim Laden" hint={error} />
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Keine Leads"
|
||||||
|
hint="Die 06:00-Routine liefert neue Leads automatisch hierher."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
|
||||||
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filtered.map((lead) => (
|
||||||
|
<LeadCard key={lead.$id} lead={lead} pipeline={pipelines[lead.leadId]} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verfolgbarer Pipeline-Balken (5 Segmente) — zeigt pro Lead, welcher
|
||||||
|
* Automatik-Schritt erledigt ist, gerade laeuft oder fehlgeschlagen ist. */
|
||||||
|
function PipelineBar({ lead }) {
|
||||||
|
const step = Number.isInteger(lead.pipelineStep) ? lead.pipelineStep : null
|
||||||
|
if (step === null) return null
|
||||||
|
const status = lead.pipelineStatus || 'idle'
|
||||||
|
|
||||||
|
let label
|
||||||
|
if (status === 'done') label = 'Automatisch verarbeitet ✓'
|
||||||
|
else if (status === 'running') label = `${PIPELINE_STEPS[step] || 'Verarbeitung'} läuft…`
|
||||||
|
else if (status === 'pending') label = 'Wartet auf den nächsten Lauf'
|
||||||
|
else if (status === 'failed') label = `Fehler: ${PIPELINE_STEPS[step] || '?'}`
|
||||||
|
else if (status === 'waiting_enrichment') label = 'Wartet auf Anreicherung (Bilder & Logo)'
|
||||||
|
else label = step > 0 ? `Stand: ${PIPELINE_STEPS[step - 1]} (manuell)` : 'Keine Automatik'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', gap: 3 }}>
|
||||||
|
{PIPELINE_STEPS.map((name, i) => {
|
||||||
|
const n = i + 1
|
||||||
|
let background = 'var(--surface-2)'
|
||||||
|
let animation = 'none'
|
||||||
|
if (n <= step) background = '#34d399'
|
||||||
|
if (status === 'running' && n === step + 1) {
|
||||||
|
background = '#3b82f6'
|
||||||
|
animation = 'leadPulse 1.2s ease-in-out infinite'
|
||||||
|
}
|
||||||
|
if (status === 'failed' && n === step + 1) background = '#f87171'
|
||||||
|
if (status === 'waiting_enrichment' && n === step + 1) background = '#fbbf24'
|
||||||
|
if (status === 'idle' && n <= step) background = '#9ca3af'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
title={`${n}. ${name}`}
|
||||||
|
style={{ flex: 1, height: 6, borderRadius: 3, background, animation }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={status === 'failed' ? 'text-red' : 'faint'}
|
||||||
|
title={status === 'failed' ? lead.pipelineError || '' : label}
|
||||||
|
style={{ fontSize: 11, marginTop: 3, display: 'flex', alignItems: 'center', gap: 4 }}
|
||||||
|
>
|
||||||
|
{status === 'failed' && <FaTriangleExclamation />}
|
||||||
|
{label} · {Math.min(step, PIPELINE_STEPS.length)}/{PIPELINE_STEPS.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schritt-Log aus leadPipeline.steps (JSON) — im aufgeklappten Bereich. */
|
||||||
|
function PipelineLog({ pipeline }) {
|
||||||
|
const steps = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(pipeline?.steps || '[]')
|
||||||
|
return Array.isArray(parsed) ? parsed : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}, [pipeline])
|
||||||
|
if (steps.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<strong>Pipeline-Verlauf:</strong>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, marginTop: 4 }}>
|
||||||
|
{steps.map((s) => (
|
||||||
|
<div key={s.n} className="flex gap-2" style={{ alignItems: 'baseline', fontSize: 12 }}>
|
||||||
|
<span style={{ color: s.ok ? '#34d399' : '#f87171', flexShrink: 0 }}>
|
||||||
|
{s.ok ? <FaCheck /> : <FaXmark />}
|
||||||
|
</span>
|
||||||
|
<span style={{ flexShrink: 0 }}>{s.n}. {s.name}</span>
|
||||||
|
<span className="faint" style={{ flexShrink: 0 }}>
|
||||||
|
{s.at ? new Date(s.at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
|
||||||
|
</span>
|
||||||
|
{s.hint && <span className="muted">— {s.hint}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der von der Pipeline geschriebene E-Mail-Entwurf, mit Kopieren-Button. */
|
||||||
|
function EmailDraft({ pipeline }) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
if (!pipeline?.emailText) return null
|
||||||
|
const copy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(
|
||||||
|
`Betreff: ${pipeline.emailBetreff || ''}\n\n${pipeline.emailText}`
|
||||||
|
)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 1600)
|
||||||
|
} catch { /* Clipboard nicht verfuegbar */ }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex gap-2" style={{ alignItems: 'center' }}>
|
||||||
|
<strong>E-Mail-Entwurf:</strong>
|
||||||
|
<Button size="sm" variant="ghost" onClick={copy} title="E-Mail in die Zwischenablage kopieren">
|
||||||
|
{copied ? <FaCheck /> : <FaCopy />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{pipeline.emailBetreff && (
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 12, marginTop: 4 }}>{pipeline.emailBetreff}</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="muted"
|
||||||
|
style={{ whiteSpace: 'pre-wrap', fontSize: 12, marginTop: 4, maxHeight: 220, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 6, padding: 8 }}
|
||||||
|
>
|
||||||
|
{pipeline.emailText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reine Info-Anzeige — Status/Notizen werden NICHT hier gepflegt,
|
||||||
|
* sondern im Akquise-Ticket des Leads (WOID-Chip). */
|
||||||
|
function LeadCard({ lead, pipeline }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const meta = statusMeta(lead.crmStatus || 'neu')
|
||||||
|
const score = lead.leadScore || 0
|
||||||
|
|
||||||
|
const websiteUrl = /^https?:\/\//.test(lead.website || '') ? lead.website : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ui-card pad" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
||||||
|
<div className="grow" style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 700, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{lead.firma}
|
||||||
|
{lead.kategorie && <Badge tone={lead.kategorie === 'A' ? 'ok' : 'muted'}>Kat. {lead.kategorie}</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="muted">
|
||||||
|
{[lead.ort, lead.branche].filter(Boolean).join(' · ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontWeight: 800, fontSize: 20, color: scoreColor(score) }}>{score}</div>
|
||||||
|
<div className="faint" style={{ fontSize: 11 }}>Score</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 6, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${Math.min(100, score)}%`, height: '100%', background: scoreColor(score) }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PipelineBar lead={lead} />
|
||||||
|
|
||||||
|
<div className="flex gap-2 wrap" style={{ alignItems: 'center' }}>
|
||||||
|
<Badge tone={meta.tone} dot>{meta.label}</Badge>
|
||||||
|
{lead.googleNote && (
|
||||||
|
<Badge tone="warn">
|
||||||
|
<FaStar /> {lead.googleNote} ({lead.anzahlBewertungen || 0})
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{lead.processed && <Badge tone="ok">Kunde angelegt</Badge>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lead.websiteStatus && (
|
||||||
|
<div className="muted" style={{ fontSize: 13 }}>
|
||||||
|
<strong>Website:</strong> {lead.websiteStatus.length > 120 && !open
|
||||||
|
? lead.websiteStatus.slice(0, 120) + '…'
|
||||||
|
: lead.websiteStatus}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 wrap" style={{ fontSize: 13 }}>
|
||||||
|
{lead.email && (
|
||||||
|
<a href={`mailto:${lead.email}`} className="muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
<FaEnvelope /> {lead.email}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{lead.telefon && (
|
||||||
|
<a href={`tel:${lead.telefon.replace(/\s/g, '')}`} className="muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
<FaPhone /> {lead.telefon}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 wrap" style={{ alignItems: 'center' }}>
|
||||||
|
{lead.googleMapsLink && (
|
||||||
|
<Button size="sm" as="a" href={lead.googleMapsLink} target="_blank" rel="noreferrer" title="Google Maps" aria-label="Google Maps">
|
||||||
|
<FaMapLocationDot />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{websiteUrl && (
|
||||||
|
<Button size="sm" as="a" href={websiteUrl} target="_blank" rel="noreferrer" title="Bestehende Website" aria-label="Bestehende Website">
|
||||||
|
<FaGlobe />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{lead.repoUrl && (
|
||||||
|
<Button size="sm" as="a" href={lead.repoUrl} target="_blank" rel="noreferrer" title="Preview-Repo (Gitea)" aria-label="Preview-Repo">
|
||||||
|
<FaGitAlt />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{lead.previewUrl && (
|
||||||
|
<Button size="sm" variant="primary" as="a" href={lead.previewUrl} target="_blank" rel="noreferrer" title="Erstellte Vorschau-Website öffnen (Login-geschützt)" aria-label="Vorschau öffnen">
|
||||||
|
<FaEye />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{lead.woid && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
as={Link}
|
||||||
|
to={`/tickets?woid=${lead.woid}`}
|
||||||
|
title={`Akquise-Ticket WOID ${lead.woid} öffnen`}
|
||||||
|
aria-label={`Ticket WOID ${lead.woid}`}
|
||||||
|
>
|
||||||
|
<FaHashtag />{lead.woid}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setOpen((o) => !o)} title={open ? 'Weniger' : 'Details'} style={{ marginLeft: 'auto' }}>
|
||||||
|
{open ? <FaChevronUp /> : <FaChevronDown />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 10, display: 'flex', flexDirection: 'column', gap: 8, fontSize: 13 }}>
|
||||||
|
{lead.ansprechpartner && <div><strong>Ansprechpartner:</strong> {lead.ansprechpartner}</div>}
|
||||||
|
{lead.adresse && <div><strong>Adresse:</strong> {lead.adresse}</div>}
|
||||||
|
{lead.kernleistungen && <div><strong>Leistungen:</strong> {lead.kernleistungen}</div>}
|
||||||
|
{lead.hauptmaengel && <div><strong>Hauptmängel:</strong> {lead.hauptmaengel}</div>}
|
||||||
|
{lead.sympathie && <div><strong>Eindruck:</strong> {lead.sympathie}</div>}
|
||||||
|
{(lead.portalLogin || lead.portalPasswort) && (
|
||||||
|
<div className="flex gap-3 wrap">
|
||||||
|
{lead.portalLogin && <CopyableCredential label="Portal-Login" value={lead.portalLogin} />}
|
||||||
|
{lead.portalPasswort && <CopyableCredential label="Portal-Passwort" value={lead.portalPasswort} secret />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<PipelineLog pipeline={pipeline} />
|
||||||
|
{lead.pipelineStatus === 'failed' && lead.pipelineError && (
|
||||||
|
<div className="text-red" style={{ fontSize: 12 }}>
|
||||||
|
<FaTriangleExclamation /> {lead.pipelineError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<EmailDraft pipeline={pipeline} />
|
||||||
|
{lead.notiz && <div><strong>Notiz (Alt-Bestand):</strong> {lead.notiz}</div>}
|
||||||
|
<div className="faint" style={{ fontSize: 11 }}>
|
||||||
|
Aufgenommen: {lead.addedAt ? new Date(lead.addedAt).toLocaleDateString('de-DE') : '-'}
|
||||||
|
{lead.leadId ? ` · ${lead.leadId}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LeadSettingsCard({ onClose }) {
|
||||||
|
const { settings, loading, saveSettings } = useLeadSettings()
|
||||||
|
const [form, setForm] = useState(null)
|
||||||
|
const [newStadt, setNewStadt] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState(null)
|
||||||
|
|
||||||
|
// Formular einmalig aus den geladenen Settings befuellen
|
||||||
|
if (!form && !loading && settings) {
|
||||||
|
setForm({
|
||||||
|
branche: settings.branche || '',
|
||||||
|
staedte: settings.staedte || [],
|
||||||
|
ganzDeutschland: Boolean(settings.ganzDeutschland),
|
||||||
|
maxLeadsProStadt: settings.maxLeadsProStadt ?? 5,
|
||||||
|
minBewertung: settings.minBewertung ?? 4.0,
|
||||||
|
emailPflicht: settings.emailPflicht !== false,
|
||||||
|
nurSchlechteWebsite: settings.nurSchlechteWebsite !== false,
|
||||||
|
zusatzHinweise: settings.zusatzHinweise || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const set = (key, value) => setForm((f) => ({ ...f, [key]: value }))
|
||||||
|
|
||||||
|
const addStadt = () => {
|
||||||
|
const s = newStadt.trim()
|
||||||
|
if (!s) return
|
||||||
|
if (!form.staedte.some((x) => x.toLowerCase() === s.toLowerCase())) {
|
||||||
|
set('staedte', [...form.staedte, s])
|
||||||
|
}
|
||||||
|
setNewStadt('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
setMsg(null)
|
||||||
|
const r = await saveSettings(form)
|
||||||
|
setBusy(false)
|
||||||
|
setMsg(r.success ? 'Gespeichert — gilt ab dem nächsten 06:00-Lauf.' : `Fehler: ${r.error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title="Sucheinstellungen der 06:00-Routine"
|
||||||
|
actions={<Button variant="ghost" size="sm" onClick={onClose}><FaXmark /></Button>}
|
||||||
|
>
|
||||||
|
{loading || !form ? (
|
||||||
|
<div className="empty"><FaSpinner className="spinner" /></div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="muted" style={{ marginTop: 0, fontSize: 13 }}>
|
||||||
|
Diese Einstellungen liest die tägliche Recherche-Routine vor jedem Lauf.
|
||||||
|
Bereits vorhandene Leads und Kunden werden automatisch übersprungen.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 wrap">
|
||||||
|
<Field label="Branche" hint="z. B. Autolackiererei, Kfz-Werkstatt, Dachdecker">
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
value={form.branche}
|
||||||
|
onChange={(e) => set('branche', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Max. neue Leads pro Stadt">
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={form.maxLeadsProStadt}
|
||||||
|
onChange={(e) => set('maxLeadsProStadt', e.target.value)}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Min. Google-Bewertung">
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
type="number"
|
||||||
|
step={0.1}
|
||||||
|
min={0}
|
||||||
|
max={5}
|
||||||
|
value={form.minBewertung}
|
||||||
|
onChange={(e) => set('minBewertung', e.target.value)}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Städte / Regionen"
|
||||||
|
hint={form.ganzDeutschland ? 'Ganz Deutschland aktiv — die Routine wählt selbst passende Regionen.' : 'Die Routine recherchiert in diesen Regionen.'}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 wrap" style={{ alignItems: 'center' }}>
|
||||||
|
{form.staedte.map((s) => (
|
||||||
|
<Badge key={s} tone={form.ganzDeutschland ? 'muted' : 'info'}>
|
||||||
|
{s}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => set('staedte', form.staedte.filter((x) => x !== s))}
|
||||||
|
style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', padding: '0 0 0 4px', display: 'inline-flex' }}
|
||||||
|
title={`${s} entfernen`}
|
||||||
|
>
|
||||||
|
<FaXmark />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Stadt hinzufügen..."
|
||||||
|
value={newStadt}
|
||||||
|
onChange={(e) => setNewStadt(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addStadt() } }}
|
||||||
|
disabled={form.ganzDeutschland}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
<Button size="sm" type="button" onClick={addStadt} disabled={form.ganzDeutschland}>
|
||||||
|
<FaPlus />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="flex gap-3 wrap" style={{ margin: '10px 0' }}>
|
||||||
|
<label className="flex gap-2" style={{ alignItems: 'center', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.ganzDeutschland}
|
||||||
|
onChange={(e) => set('ganzDeutschland', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Komplett Deutschland
|
||||||
|
</label>
|
||||||
|
<label className="flex gap-2" style={{ alignItems: 'center', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.emailPflicht}
|
||||||
|
onChange={(e) => set('emailPflicht', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Nur Leads mit auffindbarer E-Mail
|
||||||
|
</label>
|
||||||
|
<label className="flex gap-2" style={{ alignItems: 'center', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.nurSchlechteWebsite}
|
||||||
|
onChange={(e) => set('nurSchlechteWebsite', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Nur ohne / mit veralteter Website
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Zusätzliche Hinweise für die Recherche" hint="Freitext, wird der Routine wörtlich mitgegeben.">
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
rows={3}
|
||||||
|
value={form.zusatzHinweise}
|
||||||
|
onChange={(e) => set('zusatzHinweise', e.target.value)}
|
||||||
|
placeholder="z. B. Familienbetriebe bevorzugen, keine Franchise-Ketten..."
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{msg && <div className={msg.startsWith('Fehler') ? 'text-red' : 'muted'} style={{ marginBottom: 8, fontSize: 13 }}>{msg}</div>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="primary" onClick={save} disabled={busy}>
|
||||||
|
{busy ? <FaSpinner className="spinner" /> : <><FaFloppyDisk /> Speichern</>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,45 +1,42 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { format, addDays, startOfWeek } from 'date-fns'
|
import { format, addDays, startOfWeek } from 'date-fns'
|
||||||
|
import { de } from 'date-fns/locale'
|
||||||
|
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6'
|
||||||
|
import { PageHeader, Card, Button } from '../components/ui'
|
||||||
|
|
||||||
export default function PlanboardPage() {
|
export default function PlanboardPage() {
|
||||||
const [currentWeek, setCurrentWeek] = useState(startOfWeek(new Date(), { weekStartsOn: 1 }))
|
const [currentWeek, setCurrentWeek] = useState(startOfWeek(new Date(), { weekStartsOn: 1 }))
|
||||||
|
|
||||||
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeek, i))
|
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeek, i))
|
||||||
|
const navigateWeek = (dir) => setCurrentWeek((prev) => addDays(prev, dir * 7))
|
||||||
const navigateWeek = (direction) => {
|
|
||||||
setCurrentWeek(prev => addDays(prev, direction * 7))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="main-content">
|
<div className="page">
|
||||||
<header className="text-center mb-2">
|
<PageHeader
|
||||||
<h2>Planboard</h2>
|
title="Planboard"
|
||||||
</header>
|
subtitle="Wochenplanung"
|
||||||
|
actions={
|
||||||
<div className="planboard-nav text-center mb-2">
|
<div className="flex gap-2 items-center">
|
||||||
<button className="btn btn-dark" onClick={() => navigateWeek(-1)}>← Previous Week</button>
|
<Button variant="ghost" onClick={() => navigateWeek(-1)}><FaChevronLeft /></Button>
|
||||||
<span className="mx-2">
|
<span className="muted" style={{ minWidth: 200, textAlign: 'center' }}>
|
||||||
{format(currentWeek, 'dd.MM.yyyy')} - {format(addDays(currentWeek, 6), 'dd.MM.yyyy')}
|
{format(currentWeek, 'dd.MM.yyyy')} - {format(addDays(currentWeek, 6), 'dd.MM.yyyy')}
|
||||||
</span>
|
</span>
|
||||||
<button className="btn btn-dark" onClick={() => navigateWeek(1)}>Next Week →</button>
|
<Button variant="ghost" onClick={() => navigateWeek(1)}><FaChevronRight /></Button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="planboard-grid">
|
|
||||||
{weekDays.map(day => (
|
|
||||||
<div key={day.toISOString()} className="planboard-day">
|
|
||||||
<div className="day-header">
|
|
||||||
<strong>{format(day, 'EEEE')}</strong>
|
|
||||||
<span>{format(day, 'dd.MM')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="day-content">
|
|
||||||
<p className="text-grey text-small">No tasks scheduled</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div className="text-center mt-4 text-grey">
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 12 }}>
|
||||||
<p>Drag and drop tickets to schedule them on the planboard.</p>
|
{weekDays.map((day) => (
|
||||||
|
<Card key={day.toISOString()} pad={false}>
|
||||||
|
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<strong style={{ fontSize: 13 }}>{format(day, 'EEEE', { locale: de })}</strong>
|
||||||
|
<span className="faint" style={{ fontSize: 12 }}>{format(day, 'dd.MM')}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 12, minHeight: 120 }}>
|
||||||
|
<p className="faint" style={{ fontSize: 12 }}>Keine Termine</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,39 +1,758 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { FaFolder, FaPlus } from 'react-icons/fa6'
|
import {
|
||||||
|
FaFolder,
|
||||||
|
FaPlus,
|
||||||
|
FaArrowsRotate,
|
||||||
|
FaPen,
|
||||||
|
FaBoxArchive,
|
||||||
|
FaBoxOpen,
|
||||||
|
FaGlobe,
|
||||||
|
FaLock,
|
||||||
|
FaCircleInfo,
|
||||||
|
FaClone,
|
||||||
|
} from 'react-icons/fa6'
|
||||||
|
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||||
|
import { useCustomers } from '../hooks/useCustomers'
|
||||||
|
import { useWorkorders } from '../hooks/useWorkorders'
|
||||||
|
import { useEmployees } from '../hooks/useEmployees'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import {
|
||||||
|
createProjectFromTemplate,
|
||||||
|
syncGiteaRepos,
|
||||||
|
updateWebsiteProject,
|
||||||
|
archiveWebsiteProject,
|
||||||
|
unarchiveWebsiteProject,
|
||||||
|
} from '../lib/projectAdminApi'
|
||||||
|
import PreviewLinkButton from '../components/PreviewLinkButton'
|
||||||
|
import GiteaLinkButton from '../components/GiteaLinkButton'
|
||||||
|
|
||||||
|
function slugify(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORKORDER_FILTERS = { limit: 500 }
|
||||||
|
|
||||||
|
// Projekt-Typen (= "Template"/Hosting-Modus)
|
||||||
|
const TYPES = {
|
||||||
|
preview: {
|
||||||
|
label: 'Preview (mit Login)',
|
||||||
|
short: 'Preview',
|
||||||
|
badge: 'badge-info',
|
||||||
|
icon: <FaLock />,
|
||||||
|
hint: 'Login-geschuetzte Vorschau unter <subdomain>.project.webklar.com',
|
||||||
|
},
|
||||||
|
website: {
|
||||||
|
label: 'Oeffentliche Website',
|
||||||
|
short: 'Website',
|
||||||
|
badge: 'badge-ok',
|
||||||
|
icon: <FaGlobe />,
|
||||||
|
hint: 'Oeffentlich erreichbar ohne Login unter <subdomain>.project.webklar.com',
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
label: 'Vorlage',
|
||||||
|
short: 'Vorlage',
|
||||||
|
badge: 'badge-warn',
|
||||||
|
icon: <FaClone />,
|
||||||
|
hint: 'Website-Vorlage (login-geschuetzt) - erscheint nur fuer Admins in der Vorlagen-Ansicht',
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
label: 'Internes Projekt (kein Hosting)',
|
||||||
|
short: 'Projekt',
|
||||||
|
badge: 'badge-muted',
|
||||||
|
icon: <FaFolder />,
|
||||||
|
hint: 'Nur Gitea-Repository, keine Website/Subdomain',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeOf(project) {
|
||||||
|
if (project.isTemplate) return 'template'
|
||||||
|
if (project.isPublic) return 'website'
|
||||||
|
if (project.subdomain) return 'preview'
|
||||||
|
return 'project'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typen mit Hosting (Subdomain + Deploy)
|
||||||
|
const HOSTED_TYPES = new Set(['preview', 'website', 'template'])
|
||||||
|
|
||||||
|
// Gleiche Logik wie isPreviewProjectReady im Backend (auth.js):
|
||||||
|
// nur 'ready'/'deployed' gelten als tatsaechlich gehostet.
|
||||||
|
function isProjectReady(project) {
|
||||||
|
const st = String(project.provisioningStatus || project.status || '').toLowerCase()
|
||||||
|
return ['ready', 'deployed'].includes(st)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const [projects] = useState([])
|
const { projects, loading, error, fetchAllProjects, assignProjects, unassignProject } = useWebsiteProjects()
|
||||||
|
const { customers } = useCustomers()
|
||||||
|
const { workorders } = useWorkorders(WORKORDER_FILTERS)
|
||||||
|
const { employees } = useEmployees()
|
||||||
|
const { isAdmin } = useAuth()
|
||||||
|
const [filter, setFilter] = useState('all')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [customerFilter, setCustomerFilter] = useState('')
|
||||||
|
const [employeeFilter, setEmployeeFilter] = useState('')
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
|
const [createForm, setCreateForm] = useState({
|
||||||
|
displayName: '',
|
||||||
|
subdomain: '',
|
||||||
|
repoName: '',
|
||||||
|
customerId: '',
|
||||||
|
})
|
||||||
|
const [createLoading, setCreateLoading] = useState(false)
|
||||||
|
const [createError, setCreateError] = useState('')
|
||||||
|
const [syncLoading, setSyncLoading] = useState(false)
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const [assignBusyId, setAssignBusyId] = useState('')
|
||||||
|
const [hostBusyId, setHostBusyId] = useState('')
|
||||||
|
const [refreshBusyId, setRefreshBusyId] = useState('')
|
||||||
|
|
||||||
|
// Bearbeiten-Modal
|
||||||
|
const [editProject, setEditProject] = useState(null)
|
||||||
|
const [editForm, setEditForm] = useState({ projectName: '', subdomain: '', projectType: 'preview' })
|
||||||
|
const [editLoading, setEditLoading] = useState(false)
|
||||||
|
const [editError, setEditError] = useState('')
|
||||||
|
const [archiveBusy, setArchiveBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAllProjects()
|
||||||
|
}, [fetchAllProjects])
|
||||||
|
|
||||||
|
const customerMap = useMemo(() => {
|
||||||
|
const map = {}
|
||||||
|
for (const c of customers) {
|
||||||
|
map[c.$id] = c.name || c.code || c.$id
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [customers])
|
||||||
|
|
||||||
|
const ticketMap = useMemo(() => {
|
||||||
|
const map = {}
|
||||||
|
for (const wo of workorders) {
|
||||||
|
map[wo.$id] = wo.woid || wo.$id
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [workorders])
|
||||||
|
|
||||||
|
// Ticket-Dokument-ID -> Workorder (fuer Mitarbeiter-Zuordnung ueber assignedTo)
|
||||||
|
const workorderById = useMemo(() => {
|
||||||
|
const map = {}
|
||||||
|
for (const wo of workorders) map[wo.$id] = wo
|
||||||
|
return map
|
||||||
|
}, [workorders])
|
||||||
|
|
||||||
|
const sortedCustomers = useMemo(
|
||||||
|
() => [...customers].sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''))),
|
||||||
|
[customers]
|
||||||
|
)
|
||||||
|
const sortedEmployees = useMemo(
|
||||||
|
() => [...employees].sort((a, b) => String(a.displayName || '').localeCompare(String(b.displayName || ''))),
|
||||||
|
[employees]
|
||||||
|
)
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
// Vorlagen sind aus den normalen Ansichten ausgeblendet (eigene Ansicht, nur Admin)
|
||||||
|
const active = projects.filter((p) => !p.archived && !p.isTemplate)
|
||||||
|
return {
|
||||||
|
all: active.length,
|
||||||
|
unassigned: active.filter((p) => !p.customerId).length,
|
||||||
|
assigned: active.filter((p) => Boolean(p.customerId)).length,
|
||||||
|
archived: projects.filter((p) => p.archived).length,
|
||||||
|
templates: projects.filter((p) => p.isTemplate && !p.archived).length,
|
||||||
|
}
|
||||||
|
}, [projects])
|
||||||
|
|
||||||
|
const filteredProjects = useMemo(() => {
|
||||||
|
// 1) Kategorie (Dropdown)
|
||||||
|
let base
|
||||||
|
if (filter === 'templates') base = projects.filter((p) => p.isTemplate && !p.archived)
|
||||||
|
else if (filter === 'archived') base = projects.filter((p) => p.archived)
|
||||||
|
else {
|
||||||
|
const active = projects.filter((p) => !p.archived && !p.isTemplate)
|
||||||
|
if (filter === 'unassigned') base = active.filter((p) => !p.customerId)
|
||||||
|
else if (filter === 'assigned') base = active.filter((p) => Boolean(p.customerId))
|
||||||
|
else base = active
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Textsuche (Titel/Subdomain/Repo)
|
||||||
|
const q = search.trim().toLowerCase()
|
||||||
|
if (q) {
|
||||||
|
base = base.filter((p) =>
|
||||||
|
[p.projectName, p.subdomain, p.repoFullName, p.giteaRepoName].some((v) =>
|
||||||
|
String(v || '').toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Kunde
|
||||||
|
if (customerFilter) base = base.filter((p) => p.customerId === customerFilter)
|
||||||
|
|
||||||
|
// 4) Mitarbeiter (ueber das zugeordnete Ticket -> assignedTo)
|
||||||
|
if (employeeFilter) {
|
||||||
|
base = base.filter((p) => {
|
||||||
|
const wo = p.ticketId ? workorderById[p.ticketId] : null
|
||||||
|
return wo && wo.assignedTo === employeeFilter
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return base
|
||||||
|
}, [projects, filter, search, customerFilter, employeeFilter, workorderById])
|
||||||
|
|
||||||
|
const handleCreate = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setCreateLoading(true)
|
||||||
|
setCreateError('')
|
||||||
|
try {
|
||||||
|
const result = await createProjectFromTemplate({
|
||||||
|
repoName: createForm.repoName || slugify(createForm.subdomain) || slugify(createForm.displayName),
|
||||||
|
displayName: createForm.displayName,
|
||||||
|
subdomain: slugify(createForm.subdomain),
|
||||||
|
customerId: createForm.customerId || '',
|
||||||
|
ticketId: '',
|
||||||
|
})
|
||||||
|
setShowCreateModal(false)
|
||||||
|
setCreateForm({ displayName: '', subdomain: '', repoName: '', customerId: '' })
|
||||||
|
setNotice(
|
||||||
|
result.previewUrl
|
||||||
|
? `Projekt angelegt - Website: ${result.previewUrl}`
|
||||||
|
: 'Projekt (ohne Website) angelegt.'
|
||||||
|
)
|
||||||
|
await fetchAllProjects()
|
||||||
|
} catch (err) {
|
||||||
|
setCreateError(err.message || 'Projekt konnte nicht angelegt werden')
|
||||||
|
} finally {
|
||||||
|
setCreateLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSyncRepos = async () => {
|
||||||
|
setSyncLoading(true)
|
||||||
|
setNotice('')
|
||||||
|
try {
|
||||||
|
const r = await syncGiteaRepos()
|
||||||
|
setNotice(`Gitea-Sync: ${r.synced ?? '?'} Repos (${r.created ?? 0} neu, ${r.updated ?? 0} aktualisiert)`)
|
||||||
|
await fetchAllProjects()
|
||||||
|
} catch (err) {
|
||||||
|
setNotice('Gitea-Sync fehlgeschlagen: ' + (err.message || 'unbekannt'))
|
||||||
|
} finally {
|
||||||
|
setSyncLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAssignCustomer = async (project, customerId) => {
|
||||||
|
setAssignBusyId(project.$id)
|
||||||
|
setNotice('')
|
||||||
|
const r = customerId
|
||||||
|
? await assignProjects([project.$id], { customerId, ticketId: project.ticketId || '' })
|
||||||
|
: await unassignProject(project.$id)
|
||||||
|
if (!r.success) setNotice('Zuordnung fehlgeschlagen: ' + (r.error || 'unbekannt'))
|
||||||
|
await fetchAllProjects()
|
||||||
|
setAssignBusyId('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHostNow = async (project) => {
|
||||||
|
setHostBusyId(project.$id)
|
||||||
|
setNotice('')
|
||||||
|
try {
|
||||||
|
// Deploy ueber die vorhandene PATCH-Route anstossen (Typ bleibt gleich)
|
||||||
|
const r = await updateWebsiteProject(project.$id, { projectType: typeOf(project) })
|
||||||
|
setNotice(
|
||||||
|
r.deploy?.deployed
|
||||||
|
? `"${project.projectName}" wird jetzt gehostet${r.previewUrl ? ': ' + r.previewUrl : ''}`
|
||||||
|
: `Deploy angestossen, aber nicht bestaetigt: ${r.deploy?.error || 'siehe Server-Logs'}`
|
||||||
|
)
|
||||||
|
await fetchAllProjects()
|
||||||
|
} catch (err) {
|
||||||
|
setNotice('Hosten fehlgeschlagen: ' + (err.message || 'unbekannt'))
|
||||||
|
} finally {
|
||||||
|
setHostBusyId('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bereits gehostetes Projekt erneut deployen (nach Website-Ueberarbeitung neu laden)
|
||||||
|
const handleRefresh = async (project) => {
|
||||||
|
setRefreshBusyId(project.$id)
|
||||||
|
setNotice('')
|
||||||
|
try {
|
||||||
|
const r = await updateWebsiteProject(project.$id, { projectType: typeOf(project) })
|
||||||
|
setNotice(
|
||||||
|
r.deploy?.deployed
|
||||||
|
? `"${project.projectName}" wurde aktualisiert${r.previewUrl ? ': ' + r.previewUrl : ''}`
|
||||||
|
: `Aktualisierung angestossen, aber nicht bestaetigt: ${r.deploy?.error || 'siehe Server-Logs'}`
|
||||||
|
)
|
||||||
|
await fetchAllProjects()
|
||||||
|
} catch (err) {
|
||||||
|
setNotice('Aktualisieren fehlgeschlagen: ' + (err.message || 'unbekannt'))
|
||||||
|
} finally {
|
||||||
|
setRefreshBusyId('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (project) => {
|
||||||
|
setEditError('')
|
||||||
|
setEditProject(project)
|
||||||
|
setEditForm({
|
||||||
|
projectName: project.projectName || '',
|
||||||
|
subdomain: project.subdomain || '',
|
||||||
|
projectType: typeOf(project),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditSave = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!editProject) return
|
||||||
|
setEditLoading(true)
|
||||||
|
setEditError('')
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
projectName: editForm.projectName,
|
||||||
|
projectType: editForm.projectType,
|
||||||
|
}
|
||||||
|
if (editForm.projectType !== 'project') {
|
||||||
|
payload.subdomain = slugify(editForm.subdomain)
|
||||||
|
}
|
||||||
|
const result = await updateWebsiteProject(editProject.$id, payload)
|
||||||
|
setEditProject(null)
|
||||||
|
setNotice(
|
||||||
|
result.previewUrl
|
||||||
|
? `Projekt aktualisiert - ${result.isPublic ? 'oeffentlich' : 'Preview'}: ${result.previewUrl}`
|
||||||
|
: 'Projekt aktualisiert.'
|
||||||
|
)
|
||||||
|
await fetchAllProjects()
|
||||||
|
} catch (err) {
|
||||||
|
setEditError(err.message || 'Projekt konnte nicht aktualisiert werden')
|
||||||
|
} finally {
|
||||||
|
setEditLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleArchiveToggle = async (project) => {
|
||||||
|
const isArchived = Boolean(project.archived)
|
||||||
|
if (!isArchived && !window.confirm(`Repository "${project.projectName}" wirklich archivieren? Die Preview-Website wird entfernt.`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setArchiveBusy(true)
|
||||||
|
setEditError('')
|
||||||
|
try {
|
||||||
|
if (isArchived) {
|
||||||
|
await unarchiveWebsiteProject(project.$id)
|
||||||
|
setNotice(`"${project.projectName}" reaktiviert.`)
|
||||||
|
} else {
|
||||||
|
await archiveWebsiteProject(project.$id)
|
||||||
|
setNotice(`"${project.projectName}" archiviert.`)
|
||||||
|
}
|
||||||
|
setEditProject(null)
|
||||||
|
await fetchAllProjects()
|
||||||
|
} catch (err) {
|
||||||
|
setEditError(err.message || 'Archivieren fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setArchiveBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTER_OPTIONS = [
|
||||||
|
{ value: 'all', label: `Alle Projekte (${counts.all})` },
|
||||||
|
{ value: 'unassigned', label: `Unzugeordnet (${counts.unassigned})` },
|
||||||
|
{ value: 'assigned', label: `Zugeordnet (${counts.assigned})` },
|
||||||
|
// Vorlagen-Ansicht nur fuer Admins
|
||||||
|
...(isAdmin ? [{ value: 'templates', label: `Vorlagen (${counts.templates})` }] : []),
|
||||||
|
{ value: 'archived', label: `Archiviert (${counts.archived})` },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Typ-Optionen im Bearbeiten-Modal: adminOnly-Typen (Vorlage) nur fuer Admins
|
||||||
|
const typeOptions = Object.entries(TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="main-content">
|
<div className="main-content">
|
||||||
<header className="text-center mb-2">
|
<header className="text-center mb-2">
|
||||||
<h2>Projects</h2>
|
<h2>Website-Projekte</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="text-center mb-2">
|
<div className="mb-2" style={{ display: 'flex', gap: '10px', justifyContent: 'center', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
<button className="btn btn-teal">
|
<input
|
||||||
<FaPlus /> New Project
|
type="search"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Nach Titel suchen…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
style={{ flex: '1 1 220px', minWidth: '180px', maxWidth: '320px' }}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="form-control"
|
||||||
|
style={{ width: 'auto', minWidth: '170px' }}
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
{FILTER_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="form-control"
|
||||||
|
style={{ width: 'auto', minWidth: '150px' }}
|
||||||
|
value={customerFilter}
|
||||||
|
onChange={(e) => setCustomerFilter(e.target.value)}
|
||||||
|
title="Nach Kunde filtern"
|
||||||
|
>
|
||||||
|
<option value="">Alle Kunden</option>
|
||||||
|
{sortedCustomers.map((c) => (
|
||||||
|
<option key={c.$id} value={c.$id}>
|
||||||
|
{c.name}{c.code ? ` (${c.code})` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="form-control"
|
||||||
|
style={{ width: 'auto', minWidth: '150px' }}
|
||||||
|
value={employeeFilter}
|
||||||
|
onChange={(e) => setEmployeeFilter(e.target.value)}
|
||||||
|
title="Nach Mitarbeiter filtern"
|
||||||
|
>
|
||||||
|
<option value="">Alle Mitarbeiter</option>
|
||||||
|
{sortedEmployees.map((emp) => (
|
||||||
|
<option key={emp.$id} value={emp.userId}>
|
||||||
|
{emp.displayName || emp.userId}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-teal"
|
||||||
|
style={{ padding: '8px 12px' }}
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
title="Neues Projekt"
|
||||||
|
aria-label="Neues Projekt"
|
||||||
|
>
|
||||||
|
<FaPlus />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-dark"
|
||||||
|
style={{ padding: '8px 12px' }}
|
||||||
|
onClick={handleSyncRepos}
|
||||||
|
disabled={syncLoading}
|
||||||
|
title="Gitea-Repos synchronisieren"
|
||||||
|
aria-label="Gitea-Repos synchronisieren"
|
||||||
|
>
|
||||||
|
<FaArrowsRotate style={syncLoading ? { animation: 'spin 1s linear infinite' } : undefined} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
{notice && (
|
||||||
|
<div className="text-center mb-2">
|
||||||
|
<span className="text-grey">{notice}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-center text-grey">Projekte werden geladen...</p>
|
||||||
|
) : filteredProjects.length === 0 ? (
|
||||||
<div className="text-center p-4">
|
<div className="text-center p-4">
|
||||||
<FaFolder size={64} className="text-grey" />
|
<FaFolder size={64} className="text-grey" />
|
||||||
<p className="text-grey mt-2">No projects yet. Create your first project to get started.</p>
|
<p className="text-grey mt-2">
|
||||||
|
{filter === 'archived' ? 'Keine archivierten Projekte.' : 'Keine Projekte gefunden.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="projects-grid">
|
<div
|
||||||
{projects.map(project => (
|
style={{
|
||||||
<div key={project.$id} className="project-card">
|
display: 'grid',
|
||||||
<h4>{project.name}</h4>
|
gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))',
|
||||||
<p className="text-grey">{project.description}</p>
|
gap: '16px',
|
||||||
<div className="project-meta">
|
alignItems: 'start',
|
||||||
<span>{project.ticketCount || 0} tickets</span>
|
}}
|
||||||
|
>
|
||||||
|
{filteredProjects.map((project) => {
|
||||||
|
const type = typeOf(project)
|
||||||
|
const t = TYPES[type]
|
||||||
|
const hosted = HOSTED_TYPES.has(type)
|
||||||
|
const ready = isProjectReady(project)
|
||||||
|
const needsHosting = hosted && !ready && !project.archived
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={project.$id}
|
||||||
|
className="card"
|
||||||
|
style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px', opacity: project.archived ? 0.72 : 1 }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px' }}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: '15px', wordBreak: 'break-word' }}>
|
||||||
|
{project.projectName || project.giteaRepoName || '(ohne Name)'}
|
||||||
|
</div>
|
||||||
|
{project.repoFullName && (
|
||||||
|
<div className="text-grey" style={{ fontSize: '12px', marginTop: '2px' }}>
|
||||||
|
{project.repoFullName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-end', flexShrink: 0 }}>
|
||||||
|
<span className={`badge ${t.badge}`} style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{t.icon} {t.short}
|
||||||
|
</span>
|
||||||
|
{hosted && !project.archived && (
|
||||||
|
<span className={`badge ${ready ? 'badge-ok' : 'badge-warn'}`} style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{ready ? 'gehostet' : 'nicht deployt'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', fontSize: '13px' }}>
|
||||||
|
<Row label="Subdomain">
|
||||||
|
{project.subdomain
|
||||||
|
? <code>{project.subdomain}</code>
|
||||||
|
: <span className="text-grey">ohne Website</span>}
|
||||||
|
</Row>
|
||||||
|
<Row label="Ticket">
|
||||||
|
{project.ticketId ? (ticketMap[project.ticketId] || project.ticketId) : <span className="text-grey">-</span>}
|
||||||
|
</Row>
|
||||||
|
<Row label="Status">
|
||||||
|
<span className="text-grey">{project.archived ? 'archiviert' : (project.status || '-')}</span>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="form-label" style={{ fontSize: '12px' }}>Kunde</label>
|
||||||
|
<select
|
||||||
|
className="form-control"
|
||||||
|
style={{ fontSize: '13px' }}
|
||||||
|
value={project.customerId || ''}
|
||||||
|
disabled={assignBusyId === project.$id || project.archived}
|
||||||
|
onChange={(e) => handleAssignCustomer(project, e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Kein Kunde</option>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<option key={c.$id} value={c.$id}>
|
||||||
|
({c.code || ''}) {c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginTop: 'auto' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-dark"
|
||||||
|
style={{ padding: '8px 12px' }}
|
||||||
|
onClick={() => openEdit(project)}
|
||||||
|
title="Bearbeiten"
|
||||||
|
aria-label="Bearbeiten"
|
||||||
|
>
|
||||||
|
<FaPen />
|
||||||
|
</button>
|
||||||
|
{needsHosting ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-teal"
|
||||||
|
style={{ padding: '8px 12px' }}
|
||||||
|
onClick={() => handleHostNow(project)}
|
||||||
|
disabled={hostBusyId === project.$id}
|
||||||
|
title="Projekt jetzt deployen/hosten"
|
||||||
|
aria-label="Projekt jetzt deployen/hosten"
|
||||||
|
>
|
||||||
|
<FaGlobe style={hostBusyId === project.$id ? { animation: 'spin 1s linear infinite' } : undefined} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<PreviewLinkButton href={project.previewUrl} />
|
||||||
|
)}
|
||||||
|
{hosted && ready && !project.archived && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-dark"
|
||||||
|
style={{ padding: '8px 12px' }}
|
||||||
|
onClick={() => handleRefresh(project)}
|
||||||
|
disabled={refreshBusyId === project.$id}
|
||||||
|
title="Aktualisieren – Website neu deployen"
|
||||||
|
aria-label="Aktualisieren – Website neu deployen"
|
||||||
|
>
|
||||||
|
<FaArrowsRotate
|
||||||
|
style={refreshBusyId === project.$id ? { animation: 'spin 1s linear infinite' } : undefined}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<GiteaLinkButton href={project.giteaRepoUrl} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- Neues Projekt --- */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<div className="overlay">
|
||||||
|
<span className="overlay-close" onClick={() => setShowCreateModal(false)}>×</span>
|
||||||
|
<div className="overlay-content">
|
||||||
|
<h2 className="mb-2">Neues Website-Projekt</h2>
|
||||||
|
{createError && (
|
||||||
|
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||||||
|
{createError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleCreate}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Anzeigename</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={createForm.displayName}
|
||||||
|
onChange={(e) => setCreateForm((p) => ({ ...p, displayName: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Subdomain (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={createForm.subdomain}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm((p) => ({
|
||||||
|
...p,
|
||||||
|
subdomain: e.target.value,
|
||||||
|
repoName: p.repoName || slugify(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="leer lassen = Projekt ohne Website"
|
||||||
|
/>
|
||||||
|
<small className="text-grey">
|
||||||
|
Mit Subdomain wird die Website automatisch unter https://<subdomain>.project.webklar.com deployt.
|
||||||
|
Ohne Subdomain wird nur das Gitea-Repo als normales Projekt angelegt.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Repo-Name (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={createForm.repoName}
|
||||||
|
onChange={(e) => setCreateForm((p) => ({ ...p, repoName: e.target.value }))}
|
||||||
|
placeholder="leer = aus Anzeigename/Subdomain abgeleitet"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Kunde (optional)</label>
|
||||||
|
<select
|
||||||
|
className="form-control"
|
||||||
|
value={createForm.customerId}
|
||||||
|
onChange={(e) => setCreateForm((p) => ({ ...p, customerId: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">Kein Kunde</option>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<option key={c.$id} value={c.$id}>
|
||||||
|
({c.code || ''}) {c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="text-center mt-2">
|
||||||
|
<button type="submit" className="btn btn-dark" disabled={createLoading}>
|
||||||
|
{createLoading ? 'Wird angelegt...' : 'Projekt anlegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- Projekt bearbeiten --- */}
|
||||||
|
{editProject && (
|
||||||
|
<div className="overlay">
|
||||||
|
<span className="overlay-close" onClick={() => setEditProject(null)}>×</span>
|
||||||
|
<div className="overlay-content">
|
||||||
|
<h2 className="mb-2">Projekt bearbeiten</h2>
|
||||||
|
<p className="text-grey mb-2" style={{ fontSize: '13px' }}>
|
||||||
|
{editProject.repoFullName}
|
||||||
|
</p>
|
||||||
|
{editError && (
|
||||||
|
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||||||
|
{editError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleEditSave}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={editForm.projectName}
|
||||||
|
onChange={(e) => setEditForm((p) => ({ ...p, projectName: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Typ / Template</label>
|
||||||
|
<select
|
||||||
|
className="form-control"
|
||||||
|
value={editForm.projectType}
|
||||||
|
onChange={(e) => setEditForm((p) => ({ ...p, projectType: e.target.value }))}
|
||||||
|
>
|
||||||
|
{typeOptions.map(([key, meta]) => (
|
||||||
|
<option key={key} value={key}>{meta.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<small className="text-grey">
|
||||||
|
<FaCircleInfo /> {TYPES[editForm.projectType].hint}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editForm.projectType !== 'project' && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Subdomain</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={editForm.subdomain}
|
||||||
|
onChange={(e) => setEditForm((p) => ({ ...p, subdomain: e.target.value }))}
|
||||||
|
placeholder="z.B. musterfirma"
|
||||||
|
/>
|
||||||
|
<small className="text-grey">
|
||||||
|
Ergebnis: https://{slugify(editForm.subdomain) || '<subdomain>'}.project.webklar.com
|
||||||
|
{' - '}Aenderung wird neu deployt (alte Subdomain wird abgebaut).
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', marginTop: '16px', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
style={{ background: editProject.archived ? 'var(--accent)' : 'var(--danger)', color: '#fff' }}
|
||||||
|
onClick={() => handleArchiveToggle(editProject)}
|
||||||
|
disabled={archiveBusy || editLoading}
|
||||||
|
>
|
||||||
|
{editProject.archived
|
||||||
|
? <><FaBoxOpen /> Reaktivieren</>
|
||||||
|
: <><FaBoxArchive /> Repository archivieren</>}
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-dark" disabled={editLoading || archiveBusy}>
|
||||||
|
{editLoading ? 'Speichert...' : 'Speichern & synchronisieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Row({ label, children }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '8px' }}>
|
||||||
|
<span className="text-grey">{label}</span>
|
||||||
|
<span style={{ textAlign: 'right', wordBreak: 'break-word' }}>{children}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,81 +1,48 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { format, subDays } from 'date-fns'
|
import { format, subDays } from 'date-fns'
|
||||||
import { FaFileExport, FaChartBar } from 'react-icons/fa6'
|
import { FaFileExport, FaChartBar } from 'react-icons/fa6'
|
||||||
|
import { PageHeader, Card, Button, Field, EmptyState } from '../components/ui'
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const [dateRange, setDateRange] = useState({
|
const [dateRange, setDateRange] = useState({
|
||||||
from: format(subDays(new Date(), 30), 'yyyy-MM-dd'),
|
from: format(subDays(new Date(), 30), 'yyyy-MM-dd'),
|
||||||
to: format(new Date(), 'yyyy-MM-dd')
|
to: format(new Date(), 'yyyy-MM-dd'),
|
||||||
})
|
})
|
||||||
const [reportType, setReportType] = useState('tickets')
|
const [reportType, setReportType] = useState('tickets')
|
||||||
|
|
||||||
const reportTypes = [
|
const reportTypes = [
|
||||||
{ value: 'tickets', label: 'Ticket Summary' },
|
{ value: 'tickets', label: 'Ticket-Uebersicht' },
|
||||||
{ value: 'performance', label: 'Performance Report' },
|
{ value: 'performance', label: 'Leistungsbericht' },
|
||||||
{ value: 'customer', label: 'Customer Report' },
|
{ value: 'customer', label: 'Kundenbericht' },
|
||||||
{ value: 'technician', label: 'Technician Report' }
|
{ value: 'technician', label: 'Techniker-Bericht' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const handleGenerateReport = () => {
|
const handleGenerate = () => {
|
||||||
// Would generate report from Appwrite data
|
alert(`Bericht "${reportType}" von ${dateRange.from} bis ${dateRange.to}`)
|
||||||
alert(`Generating ${reportType} report from ${dateRange.from} to ${dateRange.to}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="main-content">
|
<div className="page">
|
||||||
<header className="text-center mb-2">
|
<PageHeader title="Reports" subtitle="Auswertungen und Exporte" />
|
||||||
<h2>Reports</h2>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="report-filters card p-2">
|
<Card title="Bericht erstellen">
|
||||||
<div className="filter-row">
|
<div className="flex gap-3 wrap">
|
||||||
<label>
|
<Field label="Berichtstyp">
|
||||||
Report Type:
|
<select className="form-control" value={reportType} onChange={(e) => setReportType(e.target.value)}>
|
||||||
<select
|
{reportTypes.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||||
value={reportType}
|
|
||||||
onChange={(e) => setReportType(e.target.value)}
|
|
||||||
className="input ml-1"
|
|
||||||
>
|
|
||||||
{reportTypes.map(type => (
|
|
||||||
<option key={type.value} value={type.value}>{type.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</Field>
|
||||||
|
<Field label="Von"><input className="form-control" type="date" value={dateRange.from} onChange={(e) => setDateRange((p) => ({ ...p, from: e.target.value }))} /></Field>
|
||||||
|
<Field label="Bis"><input className="form-control" type="date" value={dateRange.to} onChange={(e) => setDateRange((p) => ({ ...p, to: e.target.value }))} /></Field>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2" style={{ marginTop: 8 }}>
|
||||||
<div className="filter-row mt-2">
|
<Button variant="primary" onClick={handleGenerate}><FaChartBar /> Bericht erstellen</Button>
|
||||||
<label>
|
<Button variant="ghost"><FaFileExport /> Als PDF exportieren</Button>
|
||||||
From:
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={dateRange.from}
|
|
||||||
onChange={(e) => setDateRange(prev => ({ ...prev, from: e.target.value }))}
|
|
||||||
className="input ml-1"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="ml-2">
|
|
||||||
To:
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={dateRange.to}
|
|
||||||
onChange={(e) => setDateRange(prev => ({ ...prev, to: e.target.value }))}
|
|
||||||
className="input ml-1"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="text-center mt-2">
|
<div style={{ marginTop: 16 }}>
|
||||||
<button className="btn btn-teal" onClick={handleGenerateReport}>
|
<Card><EmptyState icon={<FaChartBar />} title="Noch kein Bericht" hint="Parameter waehlen und Bericht erstellen." /></Card>
|
||||||
<FaChartBar /> Generate Report
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-dark ml-1">
|
|
||||||
<FaFileExport /> Export PDF
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center mt-4 text-grey">
|
|
||||||
<p>Select report parameters and click Generate to view your report.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
205
src/pages/RoadmapDetailPage.jsx
Normal file
205
src/pages/RoadmapDetailPage.jsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { IconArrowLeft, IconColumnInsertRight, IconTicket, IconAlertTriangle } from '@tabler/icons-react'
|
||||||
|
import { PageHeader, Card, Button, Badge } from '../components/ui'
|
||||||
|
import RoadmapBoard from '../components/roadmap/RoadmapBoard'
|
||||||
|
import NodeDetailPanel from '../components/roadmap/NodeDetailPanel'
|
||||||
|
import RoadmapProgressBar from '../components/roadmap/RoadmapProgressBar'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useRoadmaps } from '../hooks/useRoadmaps'
|
||||||
|
import { useRoadmapNodes } from '../hooks/useRoadmapNodes'
|
||||||
|
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
|
||||||
|
import { computeProgress, NODE_KIND, ROADMAP_STATUS } from '../lib/roadmaps'
|
||||||
|
|
||||||
|
export default function RoadmapDetailPage() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const { fetchById, updateRoadmap, completePlan } = useRoadmaps()
|
||||||
|
const {
|
||||||
|
nodes, fetchNodes, createNode, updateNode, deleteNode,
|
||||||
|
connectNodes, disconnectNodes, setNodeStatus,
|
||||||
|
} = useRoadmapNodes()
|
||||||
|
|
||||||
|
const [roadmap, setRoadmap] = useState(null)
|
||||||
|
const [ticketStatus, setTicketStatus] = useState(null)
|
||||||
|
const [selectedNode, setSelectedNode] = useState(null)
|
||||||
|
const [notFound, setNotFound] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const doc = await fetchById(id)
|
||||||
|
if (!doc) {
|
||||||
|
setNotFound(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRoadmap(doc)
|
||||||
|
await fetchNodes(doc.$id)
|
||||||
|
if (doc.ticketId) {
|
||||||
|
try {
|
||||||
|
const ticket = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, doc.ticketId)
|
||||||
|
setTicketStatus(ticket.status)
|
||||||
|
} catch {
|
||||||
|
setTicketStatus(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [id, fetchById, fetchNodes])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
// Teamarbeit: bei Fenster-Fokus neu laden (einfacher Schutz gegen veraltete Ansicht)
|
||||||
|
useEffect(() => {
|
||||||
|
const onFocus = () => load()
|
||||||
|
window.addEventListener('focus', onFocus)
|
||||||
|
return () => window.removeEventListener('focus', onFocus)
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
// Ausgewaehlten Knoten mit frischen Daten aus der Liste spiegeln
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedNode) return
|
||||||
|
const fresh = nodes.find((n) => n.$id === selectedNode.$id)
|
||||||
|
if (!fresh) setSelectedNode(null)
|
||||||
|
else if (fresh !== selectedNode) setSelectedNode(fresh)
|
||||||
|
}, [nodes]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
if (notFound) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader title="Roadmap nicht gefunden" />
|
||||||
|
<Button onClick={() => navigate('/roadmaps')}><IconArrowLeft size={16} /> Zur Uebersicht</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!roadmap) {
|
||||||
|
return <div className="page"><p className="muted">Lade Roadmap…</p></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const readOnly = roadmap.status === ROADMAP_STATUS.COMPLETED
|
||||||
|
const progress = computeProgress(nodes)
|
||||||
|
const ticketClosedButActive = !readOnly && ticketStatus === 'Closed'
|
||||||
|
|
||||||
|
const handleAddNode = async (column, kind, rowCount) => {
|
||||||
|
const result = await createNode(roadmap.$id, {
|
||||||
|
title: kind === NODE_KIND.GOAL ? 'Neues Ziel' : 'Neuer Schritt',
|
||||||
|
column,
|
||||||
|
row: rowCount,
|
||||||
|
kind,
|
||||||
|
})
|
||||||
|
if (result.success) setSelectedNode(result.data)
|
||||||
|
else window.alert(result.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConnect = async (source, target) => {
|
||||||
|
const result = await connectNodes(source, target)
|
||||||
|
if (!result.success) window.alert(result.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveNode = async (node, data) => updateNode(node.$id, data)
|
||||||
|
|
||||||
|
const handleSetStatus = async (node, status) => {
|
||||||
|
const result = await setNodeStatus(roadmap, node, status, user)
|
||||||
|
if (!result.success) {
|
||||||
|
window.alert(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.allGoalsDone) {
|
||||||
|
const ok = window.confirm(
|
||||||
|
'Alle Ziel-Knoten sind fertig!\n\n' +
|
||||||
|
'Plan abschliessen und archivieren? Das Projektticket wird dabei geschlossen.'
|
||||||
|
)
|
||||||
|
if (ok) {
|
||||||
|
const done = await completePlan(roadmap, user)
|
||||||
|
if (done.success) setRoadmap(done.data)
|
||||||
|
else window.alert(`Abschliessen fehlgeschlagen: ${done.error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteNode = async (node) => {
|
||||||
|
if (!window.confirm(`Knoten "${node.title}" loeschen?`)) return
|
||||||
|
const result = await deleteNode(node)
|
||||||
|
if (!result.success) window.alert(result.error)
|
||||||
|
else setSelectedNode(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDisconnect = async (sourceId, target) => {
|
||||||
|
const result = await disconnectNodes(sourceId, target)
|
||||||
|
if (!result.success) window.alert(result.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neue Phase vor den Zielen einfuegen: Ziel-Knoten ruecken eine Spalte nach rechts. */
|
||||||
|
const handleAddColumn = async () => {
|
||||||
|
const newCount = (roadmap.columnCount || 3) + 1
|
||||||
|
if (newCount > 50) return
|
||||||
|
const goals = nodes.filter((n) => n.kind === NODE_KIND.GOAL)
|
||||||
|
for (const goal of goals) {
|
||||||
|
await updateNode(goal.$id, { column: newCount - 1 })
|
||||||
|
}
|
||||||
|
const result = await updateRoadmap(roadmap.$id, { columnCount: newCount })
|
||||||
|
if (result.success) setRoadmap(result.data)
|
||||||
|
else window.alert(result.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader
|
||||||
|
title={roadmap.title}
|
||||||
|
subtitle={`Projekt: ${roadmap.projectName || '-'}${roadmap.customerName ? ` · Kunde: ${roadmap.customerName}` : ''}`}
|
||||||
|
actions={
|
||||||
|
<div className="flex gap-2 items-center" style={{ flexWrap: 'wrap' }}>
|
||||||
|
<RoadmapProgressBar progress={progress} compact />
|
||||||
|
{readOnly && <Badge tone="ok">Abgeschlossen</Badge>}
|
||||||
|
{roadmap.woid && (
|
||||||
|
<Link to={`/tickets?woid=${roadmap.woid}`}>
|
||||||
|
<Button variant="ghost"><IconTicket size={16} /> Ticket {roadmap.woid}</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{!readOnly && (
|
||||||
|
<Button variant="ghost" onClick={handleAddColumn} title="Neue Phase vor den Zielen einfuegen">
|
||||||
|
<IconColumnInsertRight size={16} /> Spalte
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" onClick={() => navigate('/roadmaps')}><IconArrowLeft size={16} /> Zurueck</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ticketClosedButActive && (
|
||||||
|
<Card style={{ marginBottom: 16, borderColor: 'rgba(245,158,11,0.5)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13 }}>
|
||||||
|
<IconAlertTriangle size={18} style={{ color: '#fbbf24', flexShrink: 0 }} />
|
||||||
|
<span>
|
||||||
|
Das Projektticket wurde manuell geschlossen, der Plan ist aber erst zu {progress.percent}% fertig.
|
||||||
|
Statusaenderungen werden weiterhin im Ticketverlauf dokumentiert.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: selectedNode ? 'minmax(0, 1fr) 320px' : '1fr', gap: 20, alignItems: 'start' }}>
|
||||||
|
<Card pad={false} bodyStyle={{ padding: 12 }} style={{ padding: 12 }}>
|
||||||
|
<RoadmapBoard
|
||||||
|
roadmap={roadmap}
|
||||||
|
nodes={nodes}
|
||||||
|
selectedNode={selectedNode}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onSelectNode={setSelectedNode}
|
||||||
|
onAddNode={handleAddNode}
|
||||||
|
onConnect={handleConnect}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
{selectedNode && (
|
||||||
|
<NodeDetailPanel
|
||||||
|
node={selectedNode}
|
||||||
|
nodes={nodes}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onClose={() => setSelectedNode(null)}
|
||||||
|
onSave={handleSaveNode}
|
||||||
|
onSetStatus={handleSetStatus}
|
||||||
|
onDelete={handleDeleteNode}
|
||||||
|
onDisconnect={handleDisconnect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
221
src/pages/RoadmapsPage.jsx
Normal file
221
src/pages/RoadmapsPage.jsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { FaTimes } from 'react-icons/fa'
|
||||||
|
import { IconRoute, IconPlus, IconTrash, IconTicket } from '@tabler/icons-react'
|
||||||
|
import { PageHeader, Card, Button, Badge, EmptyState, Field } from '../components/ui'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useRoadmaps } from '../hooks/useRoadmaps'
|
||||||
|
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||||
|
import { fetchRoadmapNodes, computeProgress, ROADMAP_STATUS } from '../lib/roadmaps'
|
||||||
|
import RoadmapProgress from '../components/roadmap/RoadmapProgressBar'
|
||||||
|
|
||||||
|
export default function RoadmapsPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { roadmaps, loading, fetchAll, createRoadmap, deleteRoadmap } = useRoadmaps()
|
||||||
|
const { fetchAllProjects } = useWebsiteProjects()
|
||||||
|
|
||||||
|
const [projects, setProjects] = useState([])
|
||||||
|
const [progressById, setProgressById] = useState({})
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [form, setForm] = useState({ projectId: '', title: '', goalTitle: '' })
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
const [allProjects, allRoadmaps] = await Promise.all([fetchAllProjects(), fetchAll()])
|
||||||
|
if (cancelled) return
|
||||||
|
setProjects(allProjects.filter((p) => !p.isTemplate && !p.archived))
|
||||||
|
const active = allRoadmaps.filter((r) => r.status === ROADMAP_STATUS.ACTIVE)
|
||||||
|
const entries = await Promise.all(active.map(async (r) => {
|
||||||
|
try {
|
||||||
|
return [r.$id, computeProgress(await fetchRoadmapNodes(r.$id))]
|
||||||
|
} catch {
|
||||||
|
return [r.$id, null]
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
if (!cancelled) setProgressById(Object.fromEntries(entries.filter(([, p]) => p)))
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [fetchAll, fetchAllProjects])
|
||||||
|
|
||||||
|
const activeRoadmaps = roadmaps.filter((r) => r.status === ROADMAP_STATUS.ACTIVE)
|
||||||
|
const completedRoadmaps = roadmaps.filter((r) => r.status === ROADMAP_STATUS.COMPLETED)
|
||||||
|
|
||||||
|
// Nur Projekte ohne aktiven Plan anbieten (genau 1 aktiver Plan pro Projekt)
|
||||||
|
const availableProjects = useMemo(() => {
|
||||||
|
const withActivePlan = new Set(activeRoadmaps.map((r) => r.projectId))
|
||||||
|
return projects.filter((p) => !withActivePlan.has(p.$id))
|
||||||
|
}, [projects, activeRoadmaps])
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setForm({ projectId: availableProjects[0]?.$id || '', title: '', goalTitle: '' })
|
||||||
|
setError('')
|
||||||
|
setShowCreate(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const project = projects.find((p) => p.$id === form.projectId)
|
||||||
|
if (!project) {
|
||||||
|
setError('Bitte ein Projekt auswaehlen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCreating(true)
|
||||||
|
setError('')
|
||||||
|
const result = await createRoadmap(project, { title: form.title.trim(), goalTitle: form.goalTitle.trim() }, user)
|
||||||
|
setCreating(false)
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setShowCreate(false)
|
||||||
|
navigate(`/roadmaps/${result.data.roadmap.$id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (roadmap) => {
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Plan "${roadmap.title}" wirklich loeschen?\n\n` +
|
||||||
|
`Alle Knoten werden entfernt und das Projektticket (WOID ${roadmap.woid || '-'}) wird abgebrochen.`
|
||||||
|
)
|
||||||
|
if (!ok) return
|
||||||
|
const result = await deleteRoadmap(roadmap, user)
|
||||||
|
if (!result.success) window.alert(`Loeschen fehlgeschlagen: ${result.error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderRoadmapCard = (roadmap) => {
|
||||||
|
const progress = progressById[roadmap.$id]
|
||||||
|
return (
|
||||||
|
<Card key={roadmap.$id}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<Link to={`/roadmaps/${roadmap.$id}`} style={{ fontWeight: 700, fontSize: 15 }}>
|
||||||
|
{roadmap.title}
|
||||||
|
</Link>
|
||||||
|
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>
|
||||||
|
Projekt: {roadmap.projectName || '-'}
|
||||||
|
{roadmap.customerName ? ` · Kunde: ${roadmap.customerName}` : ''}
|
||||||
|
</div>
|
||||||
|
{roadmap.woid && (
|
||||||
|
<div className="faint" style={{ fontSize: 12, marginTop: 4, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<IconTicket size={14} />
|
||||||
|
<Link to={`/tickets?woid=${roadmap.woid}`}>Projektticket WOID {roadmap.woid}</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
{roadmap.status === ROADMAP_STATUS.COMPLETED
|
||||||
|
? <Badge tone="ok">Abgeschlossen</Badge>
|
||||||
|
: progress && <RoadmapProgress progress={progress} />}
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<Button variant="ghost" onClick={() => navigate(`/roadmaps/${roadmap.$id}`)}>Oeffnen</Button>
|
||||||
|
{roadmap.status === ROADMAP_STATUS.ACTIVE && (
|
||||||
|
<Button variant="ghost" title="Plan loeschen" onClick={() => handleDelete(roadmap)}>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader
|
||||||
|
title="Roadmaps"
|
||||||
|
subtitle="Projektspezifische Plaene — jeder Plan trackt sich automatisch in einem Projektticket"
|
||||||
|
actions={
|
||||||
|
<Button variant="primary" onClick={openCreate} disabled={!availableProjects.length && !loading}>
|
||||||
|
<IconPlus size={16} /> Plan erstellen
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading && !roadmaps.length ? (
|
||||||
|
<p className="muted">Lade Roadmaps…</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!activeRoadmaps.length ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<IconRoute size={40} />}
|
||||||
|
title="Noch keine aktiven Plaene"
|
||||||
|
hint="Erstelle zu einem Projekt einen Plan — das zugehoerige Projektticket wird automatisch angelegt."
|
||||||
|
action={availableProjects.length
|
||||||
|
? <Button variant="primary" onClick={openCreate}><IconPlus size={16} /> Plan erstellen</Button>
|
||||||
|
: null}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{activeRoadmaps.map(renderRoadmapCard)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{completedRoadmaps.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h3 style={{ margin: '28px 0 12px' }}>Archiv</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{completedRoadmaps.map(renderRoadmapCard)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<div className="overlay">
|
||||||
|
<span className="overlay-close" onClick={() => setShowCreate(false)}><FaTimes /></span>
|
||||||
|
<div className="overlay-content" style={{ maxWidth: 480 }}>
|
||||||
|
<h2 style={{ marginTop: 0 }}>Neuen Plan erstellen</h2>
|
||||||
|
<p className="muted" style={{ fontSize: 13 }}>
|
||||||
|
Zum Plan wird automatisch ein Projektticket erstellt, in dem der Fortschritt getrackt wird.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleCreate} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<Field label="Projekt *">
|
||||||
|
<select
|
||||||
|
value={form.projectId}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, projectId: e.target.value }))}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="" disabled>Projekt auswaehlen…</option>
|
||||||
|
{availableProjects.map((p) => (
|
||||||
|
<option key={p.$id} value={p.$id}>{p.projectName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Titel des Plans" hint="Leer lassen fuer 'Plan: <Projektname>'">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.title}
|
||||||
|
maxLength={128}
|
||||||
|
placeholder="z.B. Website-Relaunch"
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Erstes Ziel" hint="Der Zielknoten ganz rechts — weitere Ziele kannst du im Board hinzufuegen">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.goalTitle}
|
||||||
|
maxLength={128}
|
||||||
|
placeholder="z.B. Website live"
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, goalTitle: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{error && <p style={{ color: 'var(--danger, #ef4444)', fontSize: 13 }}>{error}</p>}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<Button type="button" variant="ghost" onClick={() => setShowCreate(false)}>Abbrechen</Button>
|
||||||
|
<Button type="submit" variant="primary" disabled={creating}>
|
||||||
|
{creating ? 'Erstelle…' : 'Plan + Ticket erstellen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,43 +1,58 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { format } from 'date-fns'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { FaAngleDown, FaSpinner } from 'react-icons/fa6'
|
import { FaAngleDown, FaSpinner, FaPlus, FaList, FaSliders } from 'react-icons/fa6'
|
||||||
import { useWorkorders } from '../hooks/useWorkorders'
|
import { useWorkorders } from '../hooks/useWorkorders'
|
||||||
import { useCustomers } from '../hooks/useCustomers'
|
import { useCustomers } from '../hooks/useCustomers'
|
||||||
import TicketRow from '../components/TicketRow'
|
import TicketCard from '../components/TicketCard'
|
||||||
import CreateTicketModal from '../components/CreateTicketModal'
|
import CreateTicketModal from '../components/CreateTicketModal'
|
||||||
import QuickOverviewModal from '../components/QuickOverviewModal'
|
import QuickOverviewModal from '../components/QuickOverviewModal'
|
||||||
|
import { PageHeader, Card, Button, EmptyState } from '../components/ui'
|
||||||
|
|
||||||
|
// Alle Ticket-Status; "Closed" gilt als archiviert und ist standardmaessig ausgeblendet
|
||||||
|
const ALL_STATUSES = [
|
||||||
|
'Open', 'Occupied', 'Assigned', 'Awaiting', 'Added Info',
|
||||||
|
'In Test', 'Halted', 'Aborted', 'Cancelled', 'Closed',
|
||||||
|
]
|
||||||
|
const ARCHIVED_STATUSES = ['Closed']
|
||||||
|
const DEFAULT_STATUSES = ALL_STATUSES.filter((s) => !ARCHIVED_STATUSES.includes(s))
|
||||||
|
|
||||||
export default function TicketsPage() {
|
export default function TicketsPage() {
|
||||||
const [limit, setLimit] = useState(10)
|
const [limit, setLimit] = useState(10)
|
||||||
// Aktive Filter (werden für API-Calls verwendet)
|
const [filters, setFilters] = useState({ status: DEFAULT_STATUSES, type: [], priority: [], limit: 10 })
|
||||||
const [filters, setFilters] = useState({
|
const [statusSel, setStatusSel] = useState(DEFAULT_STATUSES)
|
||||||
status: ['Open', 'Occupied', 'Assigned', 'Awaiting', 'Added Info'],
|
|
||||||
type: [],
|
|
||||||
priority: [],
|
|
||||||
limit: 10
|
|
||||||
})
|
|
||||||
// Lokale Filter-Eingaben (werden nur beim Apply angewendet)
|
|
||||||
const [localFilters, setLocalFilters] = useState({
|
const [localFilters, setLocalFilters] = useState({
|
||||||
woid: '',
|
woid: '', customer: '', userTopic: '', createdDate: '', type: '', system: '', priority: '',
|
||||||
customer: '',
|
|
||||||
userTopic: '',
|
|
||||||
createdDate: '',
|
|
||||||
type: '',
|
|
||||||
system: '',
|
|
||||||
priority: ''
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const toggleStatus = (status) => {
|
||||||
|
setStatusSel((prev) =>
|
||||||
|
prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const { workorders, loading, error, refresh, updateWorkorder, createWorkorder } = useWorkorders(filters)
|
const { workorders, loading, error, refresh, updateWorkorder, createWorkorder } = useWorkorders(filters)
|
||||||
const { customers } = useCustomers()
|
const { customers } = useCustomers()
|
||||||
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [showOverviewModal, setShowOverviewModal] = useState(false)
|
const [showOverviewModal, setShowOverviewModal] = useState(false)
|
||||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
|
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
const handleApplyFilters = () => {
|
// Deep-Link aus den Leads: /tickets?woid=123 -> Filter vorbelegen + anwenden
|
||||||
// Wende lokale Filter auf aktive Filter an
|
// (ueber ALLE Status, damit das Ticket unabhaengig vom Status gefunden wird)
|
||||||
setFilters(prev => ({
|
useEffect(() => {
|
||||||
|
const w = searchParams.get('woid')
|
||||||
|
if (!w) return
|
||||||
|
setLocalFilters((p) => ({ ...p, woid: w }))
|
||||||
|
setShowAdvanced(true)
|
||||||
|
setStatusSel(ALL_STATUSES)
|
||||||
|
setFilters((prev) => ({ ...prev, status: ALL_STATUSES, woid: w }))
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
const applyFilters = () => {
|
||||||
|
setFilters((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
status: statusSel.length ? statusSel : ALL_STATUSES,
|
||||||
woid: localFilters.woid || undefined,
|
woid: localFilters.woid || undefined,
|
||||||
customer: localFilters.customer || undefined,
|
customer: localFilters.customer || undefined,
|
||||||
userTopic: localFilters.userTopic || undefined,
|
userTopic: localFilters.userTopic || undefined,
|
||||||
@@ -45,380 +60,112 @@ export default function TicketsPage() {
|
|||||||
type: localFilters.type ? [localFilters.type] : [],
|
type: localFilters.type ? [localFilters.type] : [],
|
||||||
system: localFilters.system ? [localFilters.system] : [],
|
system: localFilters.system ? [localFilters.system] : [],
|
||||||
priority: localFilters.priority ? [parseInt(localFilters.priority)] : [],
|
priority: localFilters.priority ? [parseInt(localFilters.priority)] : [],
|
||||||
limit: limit
|
limit,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLimitChange = (e) => {
|
const handleUpdate = async (id, data) => { await updateWorkorder(id, data) }
|
||||||
const newLimit = parseInt(e.target.value)
|
|
||||||
setLimit(newLimit)
|
|
||||||
// Limit-Änderung wird sofort angewendet (kein Apply nötig)
|
|
||||||
setFilters(prev => ({ ...prev, limit: newLimit }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdate = async (id, data) => {
|
|
||||||
await updateWorkorder(id, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreate = async (data) => {
|
const handleCreate = async (data) => {
|
||||||
const result = await createWorkorder(data)
|
const result = await createWorkorder(data)
|
||||||
if (result.success) {
|
if (result.success) setShowCreateModal(false)
|
||||||
setShowCreateModal(false)
|
|
||||||
}
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
const loadMore = () => {
|
||||||
const handleLoadMore = () => {
|
const next = limit + 10
|
||||||
setLimit(prev => prev + 10)
|
setLimit(next)
|
||||||
setFilters(prev => ({ ...prev, limit: prev.limit + 10 }))
|
setFilters((prev) => ({ ...prev, limit: next }))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="main-content">
|
<div className="page">
|
||||||
{/* Sticky Header Container */}
|
<PageHeader
|
||||||
<div style={{
|
title="Tickets"
|
||||||
position: 'sticky',
|
subtitle="Alle Arbeitsauftraege auf einen Blick"
|
||||||
top: 0,
|
actions={
|
||||||
zIndex: 100,
|
<>
|
||||||
marginBottom: '24px'
|
<Button variant="ghost" onClick={() => setShowOverviewModal(true)}><FaList /> Schnelluebersicht</Button>
|
||||||
}}>
|
<Button variant="ghost" onClick={() => setShowAdvanced((s) => !s)}><FaSliders /> Filter</Button>
|
||||||
{/* Compact Control Panel */}
|
<Button variant="primary" onClick={() => setShowCreateModal(true)}><FaPlus /> Neues Ticket</Button>
|
||||||
<div style={{
|
</>
|
||||||
background: 'rgba(26, 32, 44, 0.4)',
|
}
|
||||||
backdropFilter: 'blur(25px) saturate(180%)',
|
/>
|
||||||
WebkitBackdropFilter: 'blur(25px) saturate(180%)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
border: '1px solid rgba(16, 185, 129, 0.3)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
padding: '16px',
|
|
||||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4)',
|
|
||||||
marginBottom: '16px'
|
|
||||||
}}>
|
|
||||||
{/* Title Row */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginBottom: '16px',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: '12px'
|
|
||||||
}}>
|
|
||||||
<h2 style={{
|
|
||||||
color: 'var(--dark-text)',
|
|
||||||
fontSize: '24px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
margin: 0
|
|
||||||
}}>
|
|
||||||
Tickets
|
|
||||||
</h2>
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<button
|
|
||||||
className="btn btn-dark"
|
|
||||||
onClick={() => setShowCreateModal(true)}
|
|
||||||
style={{ whiteSpace: 'nowrap' }}
|
|
||||||
>
|
|
||||||
CREATE NEW TICKET
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-dark"
|
|
||||||
onClick={() => setShowOverviewModal(true)}
|
|
||||||
style={{ whiteSpace: 'nowrap' }}
|
|
||||||
>
|
|
||||||
QUICK OVERVIEW
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-green"
|
|
||||||
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
|
||||||
style={{ whiteSpace: 'nowrap' }}
|
|
||||||
>
|
|
||||||
{showAdvancedFilters ? '▲ Hide Filters' : '▼ Show Filters'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Search Bar */}
|
<Card pad className="mb-2" style={{ marginBottom: 16 }}>
|
||||||
<div style={{
|
<div className="toolbar" style={{ marginBottom: showAdvanced ? 16 : 0 }}>
|
||||||
display: 'grid',
|
<input className="form-control" style={{ maxWidth: 160 }} placeholder="WOID" value={localFilters.woid} onChange={(e) => setLocalFilters({ ...localFilters, woid: e.target.value })} />
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
<input className="form-control" style={{ maxWidth: 220 }} placeholder="Kunde" value={localFilters.customer} onChange={(e) => setLocalFilters({ ...localFilters, customer: e.target.value })} />
|
||||||
gap: '12px',
|
<input className="form-control search-input" placeholder="Benutzer / Thema" value={localFilters.userTopic} onChange={(e) => setLocalFilters({ ...localFilters, userTopic: e.target.value })} />
|
||||||
alignItems: 'center'
|
<Button variant="primary" onClick={applyFilters}>Anwenden</Button>
|
||||||
}}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="WOID"
|
|
||||||
className="form-control"
|
|
||||||
style={{ margin: 0 }}
|
|
||||||
value={localFilters.woid || ''}
|
|
||||||
onChange={(e) => setLocalFilters({ ...localFilters, woid: e.target.value })}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Customer"
|
|
||||||
className="form-control"
|
|
||||||
style={{ margin: 0 }}
|
|
||||||
value={localFilters.customer || ''}
|
|
||||||
onChange={(e) => setLocalFilters({ ...localFilters, customer: e.target.value })}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="User"
|
|
||||||
className="form-control"
|
|
||||||
style={{ margin: 0 }}
|
|
||||||
value={localFilters.userTopic || ''}
|
|
||||||
onChange={(e) => setLocalFilters({ ...localFilters, userTopic: e.target.value })}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="btn btn-green"
|
|
||||||
onClick={handleApplyFilters}
|
|
||||||
style={{ margin: 0 }}
|
|
||||||
>
|
|
||||||
Apply!
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Advanced Filters - Collapsible */}
|
|
||||||
{showAdvancedFilters && (
|
|
||||||
<div style={{
|
|
||||||
marginTop: '16px',
|
|
||||||
paddingTop: '16px',
|
|
||||||
borderTop: '1px solid rgba(16, 185, 129, 0.2)'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
|
||||||
gap: '12px',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: '16px'
|
|
||||||
}}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Created Date"
|
|
||||||
className="form-control"
|
|
||||||
style={{ margin: 0 }}
|
|
||||||
value={localFilters.createdDate || ''}
|
|
||||||
onChange={(e) => setLocalFilters({ ...localFilters, createdDate: e.target.value })}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
className="form-control"
|
|
||||||
style={{ margin: 0 }}
|
|
||||||
value={localFilters.type || ''}
|
|
||||||
onChange={(e) => setLocalFilters({ ...localFilters, type: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="">Type / Location</option>
|
|
||||||
<option>Home Office</option>
|
|
||||||
<option>Holidays</option>
|
|
||||||
<option>Trip</option>
|
|
||||||
<option>Supportrequest</option>
|
|
||||||
<option>Change Request</option>
|
|
||||||
<option>Maintenance</option>
|
|
||||||
<option>Project</option>
|
|
||||||
<option>Procurement</option>
|
|
||||||
<option>Emergency Call</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="form-control"
|
|
||||||
style={{ margin: 0 }}
|
|
||||||
value={localFilters.system || ''}
|
|
||||||
onChange={(e) => setLocalFilters({ ...localFilters, system: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="">System</option>
|
|
||||||
<option>Client</option>
|
|
||||||
<option>Server</option>
|
|
||||||
<option>Network</option>
|
|
||||||
<option>EDI</option>
|
|
||||||
<option>TOS</option>
|
|
||||||
<option>Reports</option>
|
|
||||||
<option>n/a</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="form-control"
|
|
||||||
style={{ margin: 0 }}
|
|
||||||
value={localFilters.priority || ''}
|
|
||||||
onChange={(e) => setLocalFilters({ ...localFilters, priority: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="">Priority</option>
|
|
||||||
<option value="0">None</option>
|
|
||||||
<option value="1">Low</option>
|
|
||||||
<option value="2">Medium</option>
|
|
||||||
<option value="3">High</option>
|
|
||||||
<option value="4">Critical</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Selection Buttons */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '8px',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
marginBottom: '16px'
|
|
||||||
}}>
|
|
||||||
<button
|
|
||||||
className="btn btn-green"
|
|
||||||
onClick={() => {
|
|
||||||
setLocalFilters(prev => ({ ...prev, type: 'Procurement' }))
|
|
||||||
setFilters(prev => ({ ...prev, type: ['Procurement'] }))
|
|
||||||
setTimeout(() => refresh(), 0)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Procurements
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-green"
|
|
||||||
onClick={() => {
|
|
||||||
setLocalFilters(prev => ({ ...prev, priority: '4' }))
|
|
||||||
setFilters(prev => ({ ...prev, priority: [4] }))
|
|
||||||
setTimeout(() => refresh(), 0)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Criticals
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-green"
|
|
||||||
onClick={() => {
|
|
||||||
setLocalFilters(prev => ({ ...prev, priority: '3' }))
|
|
||||||
setFilters(prev => ({ ...prev, priority: [3] }))
|
|
||||||
setTimeout(() => refresh(), 0)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Highs
|
|
||||||
</button>
|
|
||||||
<div style={{
|
|
||||||
width: '1px',
|
|
||||||
height: '32px',
|
|
||||||
background: 'rgba(16, 185, 129, 0.3)',
|
|
||||||
margin: '0 8px'
|
|
||||||
}}></div>
|
|
||||||
<button
|
|
||||||
className="btn btn-green"
|
|
||||||
onClick={() => {
|
|
||||||
setLimit(10)
|
|
||||||
setFilters(prev => ({ ...prev, limit: 10 }))
|
|
||||||
setTimeout(() => refresh(), 0)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
10
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-green"
|
|
||||||
onClick={() => {
|
|
||||||
setLimit(25)
|
|
||||||
setFilters(prev => ({ ...prev, limit: 25 }))
|
|
||||||
setTimeout(() => refresh(), 0)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
25
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Slider */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '16px',
|
|
||||||
alignItems: 'center'
|
|
||||||
}}>
|
|
||||||
<span style={{ color: 'var(--dark-text)', minWidth: '120px' }}>
|
|
||||||
Load Limit: {limit}
|
|
||||||
</span>
|
|
||||||
<div style={{ flex: '1' }}>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="5"
|
|
||||||
max="50"
|
|
||||||
value={limit}
|
|
||||||
className="slider"
|
|
||||||
onChange={handleLimitChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<table className="table table-hover">
|
{showAdvanced && (
|
||||||
<tbody>
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 16 }}>
|
||||||
{loading ? (
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12 }}>
|
||||||
<tr>
|
<input type="date" className="form-control" value={localFilters.createdDate} onChange={(e) => setLocalFilters({ ...localFilters, createdDate: e.target.value })} />
|
||||||
<td colSpan={10} className="text-center p-2">
|
<select className="form-control" value={localFilters.type} onChange={(e) => setLocalFilters({ ...localFilters, type: e.target.value })}>
|
||||||
<FaSpinner className="spinner" size={32} />
|
<option value="">Typ / Ort</option>
|
||||||
<p>Loading...</p>
|
<option>Supportrequest</option><option>Change Request</option><option>Maintenance</option>
|
||||||
</td>
|
<option>Project</option><option>Procurement</option><option>Emergency Call</option>
|
||||||
</tr>
|
<option>Home Office</option><option>Holidays</option><option>Trip</option>
|
||||||
) : error ? (
|
</select>
|
||||||
<tr>
|
<select className="form-control" value={localFilters.system} onChange={(e) => setLocalFilters({ ...localFilters, system: e.target.value })}>
|
||||||
<td colSpan={10} className="text-center p-2 text-red">
|
<option value="">System</option>
|
||||||
Error: {error}
|
<option>Client</option><option>Server</option><option>Network</option>
|
||||||
</td>
|
<option>EDI</option><option>TOS</option><option>Reports</option><option>n/a</option>
|
||||||
</tr>
|
</select>
|
||||||
) : workorders.length === 0 ? (
|
<select className="form-control" value={localFilters.priority} onChange={(e) => setLocalFilters({ ...localFilters, priority: e.target.value })}>
|
||||||
<tr>
|
<option value="">Prioritaet</option>
|
||||||
<td colSpan={10} className="text-center p-2">
|
<option value="0">Keine</option><option value="1">Niedrig</option><option value="2">Mittel</option>
|
||||||
No tickets found matching your filters.
|
<option value="3">Hoch</option><option value="4">Kritisch</option>
|
||||||
</td>
|
</select>
|
||||||
</tr>
|
</div>
|
||||||
) : (
|
<div style={{ marginTop: 16 }}>
|
||||||
workorders.map(ticket => (
|
<div className="flex gap-2 wrap" style={{ alignItems: 'center', marginBottom: 8 }}>
|
||||||
<TicketRow
|
<span className="muted" style={{ fontSize: 13, fontWeight: 600 }}>Status</span>
|
||||||
key={ticket.$id}
|
<button type="button" onClick={() => setStatusSel(DEFAULT_STATUSES)} style={{ fontSize: 12, background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0 }}>Standard</button>
|
||||||
ticket={ticket}
|
<button type="button" onClick={() => setStatusSel(ALL_STATUSES)} style={{ fontSize: 12, background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0 }}>Alle inkl. Archiv</button>
|
||||||
onUpdate={handleUpdate}
|
</div>
|
||||||
/>
|
<div className="flex gap-3 wrap">
|
||||||
))
|
{ALL_STATUSES.map((status) => (
|
||||||
)}
|
<label key={status} className="flex items-center gap-2" style={{ cursor: 'pointer', fontSize: 14 }}>
|
||||||
</tbody>
|
<input type="checkbox" checked={statusSel.includes(status)} onChange={() => toggleStatus(status)} />
|
||||||
</table>
|
{status}
|
||||||
|
{ARCHIVED_STATUSES.includes(status) && <span className="faint" style={{ fontSize: 11 }}>(Archiv)</span>}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 wrap" style={{ marginTop: 12 }}>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => { setFilters((p) => ({ ...p, priority: [4] })); setTimeout(refresh, 0) }}>Kritische</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => { setFilters((p) => ({ ...p, priority: [3] })); setTimeout(refresh, 0) }}>Hohe</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => { setFilters((p) => ({ ...p, type: ['Procurement'] })); setTimeout(refresh, 0) }}>Beschaffung</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
{workorders.length > 0 && workorders.length >= limit && (
|
{loading ? (
|
||||||
<div style={{ textAlign: 'center', marginTop: '24px' }}>
|
<div className="empty"><FaSpinner className="spinner" /></div>
|
||||||
<button
|
) : error ? (
|
||||||
className="btn btn-green"
|
<Card><div className="text-red">Fehler: {error}</div></Card>
|
||||||
style={{
|
) : workorders.length === 0 ? (
|
||||||
padding: '16px 32px',
|
<Card><EmptyState title="Keine Tickets" hint="Keine Tickets passen zu deinen Filtern." action={<Button variant="primary" onClick={() => setShowCreateModal(true)}><FaPlus /> Neues Ticket</Button>} /></Card>
|
||||||
fontSize: '16px',
|
) : (
|
||||||
display: 'flex',
|
<div className="tickets-list">
|
||||||
alignItems: 'center',
|
{workorders.map((ticket) => (
|
||||||
gap: '8px',
|
<TicketCard key={ticket.$id} ticket={ticket} onUpdate={handleUpdate} />
|
||||||
margin: '0 auto'
|
))}
|
||||||
}}
|
|
||||||
onClick={handleLoadMore}
|
|
||||||
>
|
|
||||||
Load More <FaAngleDown size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{
|
{workorders.length > 0 && workorders.length >= limit && (
|
||||||
background: 'rgba(45, 55, 72, 0.95)',
|
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||||
borderRadius: '12px',
|
<Button variant="ghost" onClick={loadMore}>Mehr laden <FaAngleDown /></Button>
|
||||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
</div>
|
||||||
padding: '16px',
|
)}
|
||||||
marginTop: '24px',
|
|
||||||
textAlign: 'center',
|
|
||||||
color: '#a0aec0'
|
|
||||||
}}>
|
|
||||||
<p style={{ margin: 0 }}>
|
|
||||||
Summary: Listed a total of <span style={{
|
|
||||||
color: 'var(--green-primary)',
|
|
||||||
fontWeight: 'bold'
|
|
||||||
}}>{workorders.length}</span> Workorders.
|
|
||||||
<br />EOL =)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CreateTicketModal
|
<CreateTicketModal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} onCreate={handleCreate} customers={customers} />
|
||||||
isOpen={showCreateModal}
|
<QuickOverviewModal isOpen={showOverviewModal} onClose={() => setShowOverviewModal(false)} workorders={workorders} />
|
||||||
onClose={() => setShowCreateModal(false)}
|
|
||||||
onCreate={handleCreate}
|
|
||||||
customers={customers}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<QuickOverviewModal
|
|
||||||
isOpen={showOverviewModal}
|
|
||||||
onClose={() => setShowOverviewModal(false)}
|
|
||||||
workorders={workorders}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,21 +5,69 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--dark-bg: #1a202c;
|
/* Surfaces (calm dark) */
|
||||||
--dark-card-bg: rgba(45, 55, 72, 0.95);
|
--bg: #0e131a;
|
||||||
--dark-text: #e2e8f0;
|
--surface-1: #151b23;
|
||||||
--green-primary: #10b981;
|
--surface-2: #1b2430;
|
||||||
--green-secondary: #059669;
|
--surface-3: #222d3a;
|
||||||
|
--surface-hover: #243040;
|
||||||
|
|
||||||
|
/* Borders / lines */
|
||||||
|
--border: rgba(255, 255, 255, 0.08);
|
||||||
|
--border-strong: rgba(255, 255, 255, 0.14);
|
||||||
|
--border-accent: rgba(16, 185, 129, 0.35);
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text: #e6edf3;
|
||||||
|
--text-muted: #9aa7b4;
|
||||||
|
--text-faint: #6b7785;
|
||||||
|
|
||||||
|
/* Accent */
|
||||||
|
--accent: #10b981;
|
||||||
|
--accent-2: #059669;
|
||||||
|
--accent-soft: rgba(16, 185, 129, 0.14);
|
||||||
|
|
||||||
|
/* Semantic */
|
||||||
|
--info: #3b82f6;
|
||||||
|
--warn: #f59e0b;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--ok: #10b981;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px;
|
||||||
|
--sp-4: 16px; --sp-5: 24px; --sp-6: 32px;
|
||||||
|
|
||||||
|
/* Radius */
|
||||||
|
--r-sm: 8px; --r-md: 12px; --r-lg: 16px; --r-pill: 999px;
|
||||||
|
|
||||||
|
/* Elevation */
|
||||||
|
--shadow-1: 0 1px 2px rgba(0,0,0,0.3);
|
||||||
|
--shadow-2: 0 4px 14px rgba(0,0,0,0.3);
|
||||||
|
--shadow-3: 0 12px 32px rgba(0,0,0,0.4);
|
||||||
|
|
||||||
|
/* Legacy aliases (Rueckwaertskompatibilitaet) */
|
||||||
|
--dark-bg: var(--bg);
|
||||||
|
--dark-card-bg: var(--surface-2);
|
||||||
|
--dark-text: var(--text);
|
||||||
|
--green-primary: var(--accent);
|
||||||
|
--green-secondary: var(--accent-2);
|
||||||
--gray-700: #374151;
|
--gray-700: #374151;
|
||||||
--gray-800: #1f2937;
|
--gray-800: #1f2937;
|
||||||
--gray-900: #111827;
|
--gray-900: #111827;
|
||||||
|
|
||||||
|
--sidebar-w: 248px;
|
||||||
|
--sidebar-w-collapsed: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Lato', sans-serif;
|
font-family: 'Lato', system-ui, -apple-system, sans-serif;
|
||||||
background-color: var(--dark-bg);
|
background: radial-gradient(1200px 700px at 80% -10%, rgba(16,185,129,0.06), transparent 60%),
|
||||||
color: var(--dark-text);
|
radial-gradient(900px 600px at -10% 110%, rgba(59,130,246,0.05), transparent 55%),
|
||||||
|
var(--bg);
|
||||||
|
background-attachment: fixed;
|
||||||
|
color: var(--text);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar Styling */
|
/* Scrollbar Styling */
|
||||||
@@ -74,8 +122,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
margin-top: 64px;
|
padding: var(--sp-5);
|
||||||
padding: 16px;
|
max-width: 1320px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Navbar */
|
/* Navbar */
|
||||||
@@ -432,19 +482,19 @@ textarea.form-control {
|
|||||||
color: var(--dark-text);
|
color: var(--dark-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status Colors */
|
/* Status Colors (ruhiger, semantisch) */
|
||||||
.status-open { background: #4CAF50 !important; color: #fff !important; }
|
.status-open { background: rgba(16,185,129,0.18) !important; color: #34d399 !important; }
|
||||||
.status-occupied { background: #607D8B !important; color: #fff !important; }
|
.status-occupied { background: rgba(148,163,184,0.18) !important; color: #cbd5e0 !important; }
|
||||||
.status-assigned { background: #009688 !important; color: #fff !important; }
|
.status-assigned { background: rgba(20,184,166,0.18) !important; color: #2dd4bf !important; }
|
||||||
.status-awaiting { background: #ff9800 !important; color: #fff !important; }
|
.status-awaiting { background: rgba(245,158,11,0.18) !important; color: #fbbf24 !important; }
|
||||||
.status-closed { background: #9e9e9e !important; color: #fff !important; }
|
.status-closed { background: rgba(148,163,184,0.14) !important; color: #94a3b8 !important; }
|
||||||
|
|
||||||
/* Priority Colors */
|
/* Priority Colors (ruhiger, semantisch) */
|
||||||
.priority-none { background: #2196F3 !important; color: #fff !important; }
|
.priority-none { background: rgba(59,130,246,0.16) !important; color: #93c5fd !important; }
|
||||||
.priority-low { background: #4CAF50 !important; color: #fff !important; }
|
.priority-low { background: rgba(16,185,129,0.16) !important; color: #34d399 !important; }
|
||||||
.priority-medium { background: #ffc107 !important; color: #000 !important; }
|
.priority-medium { background: rgba(245,158,11,0.18) !important; color: #fbbf24 !important; }
|
||||||
.priority-high { background: #ff9800 !important; color: #fff !important; }
|
.priority-high { background: rgba(249,115,22,0.18) !important; color: #fb923c !important; }
|
||||||
.priority-critical { background: #f44336 !important; color: #fff !important; }
|
.priority-critical { background: rgba(239,68,68,0.18) !important; color: #f87171 !important; }
|
||||||
|
|
||||||
/* Dropdown */
|
/* Dropdown */
|
||||||
.dropdown {
|
.dropdown {
|
||||||
@@ -531,6 +581,109 @@ textarea.form-control {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ticket-projects-overlay { padding: 24px; }
|
||||||
|
.ticket-projects-modal { width: 100%; max-width: none; min-height: calc(100vh - 48px); box-sizing: border-box; }
|
||||||
|
.ticket-projects-modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||||
|
.ticket-projects-modal-header h2 { margin: 0; }
|
||||||
|
.ticket-projects-modal-grid { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(320px, 1fr); gap: 24px; align-items: start; }
|
||||||
|
.ticket-projects-modal-section { background: rgba(45,55,72,0.65); border: 1px solid rgba(59,130,246,0.25); border-radius: 12px; padding: 16px 18px; margin-bottom: 16px; }
|
||||||
|
.ticket-projects-modal-section h6 { font-weight: bold; margin: 0 0 12px; }
|
||||||
|
.ticket-projects-modal-side { display: flex; flex-direction: column; }
|
||||||
|
.ticket-projects-available-list { max-height: min(50vh, 520px); overflow-y: auto; margin-bottom: 8px; display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 6px 16px; }
|
||||||
|
.ticket-projects-available-item { display: flex; gap: 8px; align-items: flex-start; cursor: pointer; padding: 4px 0; }
|
||||||
|
.ticket-projects-create-form { display: grid; grid-template-columns: 1fr 1fr auto; gap: 10px; align-items: end; }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.ticket-projects-modal-grid { grid-template-columns: 1fr; }
|
||||||
|
.ticket-projects-create-form { grid-template-columns: 1fr; }
|
||||||
|
.ticket-projects-available-list { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-overlay {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
min-height: calc(100vh - 48px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-modal-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-modal-section {
|
||||||
|
background: rgba(45, 55, 72, 0.65);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.25);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-modal-section h6 {
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-modal-side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-available-list {
|
||||||
|
max-height: min(50vh, 520px);
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 6px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-available-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-create-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.ticket-projects-modal-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-create-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-projects-available-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: rgba(45, 55, 72, 0.98) !important;
|
background: rgba(45, 55, 72, 0.98) !important;
|
||||||
border: 1px solid rgba(16, 185, 129, 0.3) !important;
|
border: 1px solid rgba(16, 185, 129, 0.3) !important;
|
||||||
@@ -686,8 +839,293 @@ textarea.form-control {
|
|||||||
margin-top: 32px;
|
margin-top: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ticket-Detail Tabs */
|
||||||
|
.ticket-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-bottom: 1px solid rgba(16, 185, 129, 0.2);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
.ticket-tab {
|
||||||
|
background: rgba(45, 55, 72, 0.5);
|
||||||
|
color: #cbd5e0;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.15);
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.ticket-tab:hover {
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
color: var(--dark-text);
|
||||||
|
}
|
||||||
|
.ticket-tab-active {
|
||||||
|
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.ticket-tab-content {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.col-6 { width: 100%; }
|
.col-6 { width: 100%; }
|
||||||
.navbar-nav { display: none; }
|
.navbar-nav { display: none; }
|
||||||
|
.ticket-tab { padding: 8px 12px; font-size: 13px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================= */
|
||||||
|
/* =================== REDESIGN (Designsystem) =============== */
|
||||||
|
/* ============================================================= */
|
||||||
|
|
||||||
|
/* --- App-Shell --- */
|
||||||
|
.app-shell { display: flex; height: 100vh; overflow: hidden; }
|
||||||
|
.app-main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.app-scroll { flex: 1; overflow-y: auto; }
|
||||||
|
|
||||||
|
/* --- Sidebar (fix, Labels sichtbar, gruppiert) --- */
|
||||||
|
.side {
|
||||||
|
width: var(--sidebar-w);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: linear-gradient(180deg, var(--surface-1), var(--bg));
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
.side.collapsed { width: var(--sidebar-w-collapsed); }
|
||||||
|
.side-head {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4); border-bottom: 1px solid var(--border);
|
||||||
|
min-height: 64px;
|
||||||
|
}
|
||||||
|
.side-logo {
|
||||||
|
width: 32px; height: 32px; border-radius: 9px; flex-shrink: 0;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: #fff; font-weight: 800;
|
||||||
|
}
|
||||||
|
.side-brand { font-weight: 800; font-size: 16px; letter-spacing: 0.2px; white-space: nowrap; }
|
||||||
|
.side-brand small { display: block; font-weight: 500; font-size: 11px; color: var(--text-muted); }
|
||||||
|
.side-nav { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-2); }
|
||||||
|
.side-group { margin-bottom: var(--sp-4); }
|
||||||
|
.side-group-label {
|
||||||
|
font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.7px;
|
||||||
|
color: var(--text-faint); padding: 0 var(--sp-3) var(--sp-2);
|
||||||
|
}
|
||||||
|
.side.collapsed .side-group-label,
|
||||||
|
.side.collapsed .side-link-label,
|
||||||
|
.side.collapsed .side-brand { display: none; }
|
||||||
|
.side-link {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
|
padding: 10px var(--sp-3); margin: 2px var(--sp-1);
|
||||||
|
border-radius: var(--r-sm); color: var(--text-muted);
|
||||||
|
text-decoration: none; transition: all 0.15s ease; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.side-link svg { width: 20px; height: 20px; flex-shrink: 0; }
|
||||||
|
.side-link:hover { background: var(--surface-2); color: var(--text); }
|
||||||
|
.side-link.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||||
|
.side-link.active svg { color: var(--accent); }
|
||||||
|
.side-foot { border-top: 1px solid var(--border); padding: var(--sp-3); }
|
||||||
|
.side-toggle {
|
||||||
|
background: transparent; border: none; color: var(--text-muted);
|
||||||
|
cursor: pointer; padding: var(--sp-2); border-radius: var(--r-sm);
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2); width: 100%;
|
||||||
|
}
|
||||||
|
.side-toggle:hover { background: var(--surface-2); color: var(--text); }
|
||||||
|
|
||||||
|
/* --- Page header + container --- */
|
||||||
|
.page { padding: var(--sp-5); max-width: 1320px; margin: 0 auto; width: 100%; }
|
||||||
|
.page-header {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
|
gap: var(--sp-4); margin-bottom: var(--sp-5); flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.page-title { font-size: 24px; font-weight: 800; margin: 0; }
|
||||||
|
.page-subtitle { color: var(--text-muted); font-size: 14px; margin-top: 4px; }
|
||||||
|
.page-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* --- UI Card / Section --- */
|
||||||
|
.ui-card {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
box-shadow: var(--shadow-1);
|
||||||
|
}
|
||||||
|
.ui-card.pad { padding: var(--sp-4); }
|
||||||
|
.ui-card-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: var(--sp-3); padding: var(--sp-4); border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.ui-card-head h3 { margin: 0; font-size: 16px; font-weight: 700; }
|
||||||
|
.ui-card-body { padding: var(--sp-4); }
|
||||||
|
|
||||||
|
/* --- Buttons (Redesign-Varianten) --- */
|
||||||
|
.ui-btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
padding: 9px 16px; border-radius: var(--r-sm); border: 1px solid transparent;
|
||||||
|
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s ease;
|
||||||
|
background: var(--surface-2); color: var(--text); text-decoration: none;
|
||||||
|
}
|
||||||
|
.ui-btn:hover { background: var(--surface-hover); }
|
||||||
|
.ui-btn-primary { background: var(--accent); color: #06231a; border-color: transparent; }
|
||||||
|
.ui-btn-primary:hover { background: #34d399; }
|
||||||
|
.ui-btn-ghost { background: transparent; border-color: var(--border-strong); color: var(--text-muted); }
|
||||||
|
.ui-btn-ghost:hover { background: var(--surface-2); color: var(--text); }
|
||||||
|
.ui-btn-danger { background: rgba(239,68,68,0.15); color: #f87171; }
|
||||||
|
.ui-btn-danger:hover { background: rgba(239,68,68,0.25); }
|
||||||
|
.ui-btn-sm { padding: 6px 10px; font-size: 13px; }
|
||||||
|
.ui-btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* --- Badges / Pills --- */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 3px 10px; border-radius: var(--r-pill);
|
||||||
|
font-size: 12px; font-weight: 600; line-height: 1.4;
|
||||||
|
background: var(--surface-2); color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.badge .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||||
|
.badge-info { background: rgba(59,130,246,0.16); color: #93c5fd; }
|
||||||
|
.badge-ok { background: rgba(16,185,129,0.16); color: #34d399; }
|
||||||
|
.badge-warn { background: rgba(245,158,11,0.18); color: #fbbf24; }
|
||||||
|
.badge-danger { background: rgba(239,68,68,0.18); color: #f87171; }
|
||||||
|
.badge-muted { background: var(--surface-2); color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* --- KPI / Stat cards --- */
|
||||||
|
.kpi-grid {
|
||||||
|
display: grid; gap: var(--sp-4);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
margin-bottom: var(--sp-5);
|
||||||
|
}
|
||||||
|
.kpi {
|
||||||
|
background: var(--surface-1); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md); padding: var(--sp-4);
|
||||||
|
display: flex; flex-direction: column; gap: 6px; position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.kpi-accent { position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent); }
|
||||||
|
.kpi-label { color: var(--text-muted); font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.kpi-value { font-size: 28px; font-weight: 800; }
|
||||||
|
.kpi-sub { font-size: 12px; color: var(--text-faint); }
|
||||||
|
/* legacy dashboard stat classes -> new look */
|
||||||
|
.stats-grid { display: grid; gap: var(--sp-4); grid-template-columns: repeat(auto-fit, minmax(200px,1fr)); margin-bottom: var(--sp-5); }
|
||||||
|
.stat-card { background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--sp-4); display: flex; align-items: center; gap: var(--sp-4); }
|
||||||
|
.stat-info { display: flex; flex-direction: column; }
|
||||||
|
.stat-value { font-size: 26px; font-weight: 800; }
|
||||||
|
.stat-label { color: var(--text-muted); font-size: 13px; }
|
||||||
|
.dashboard-section { margin-top: var(--sp-5); }
|
||||||
|
.quick-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; margin-top: var(--sp-3); }
|
||||||
|
|
||||||
|
/* --- Ticket Card (ersetzt Tabelle) --- */
|
||||||
|
.tickets-list { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
|
.tcard {
|
||||||
|
background: var(--surface-1); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md); overflow: visible; transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.tcard:hover { border-color: var(--border-strong); }
|
||||||
|
.tcard.open { border-color: var(--border-accent); }
|
||||||
|
.tcard-detail { border-radius: 0 0 var(--r-md) var(--r-md); }
|
||||||
|
.tcard-main {
|
||||||
|
display: grid; grid-template-columns: 92px 1fr auto; gap: var(--sp-4);
|
||||||
|
padding: var(--sp-4); align-items: center; cursor: pointer;
|
||||||
|
}
|
||||||
|
.tcard-woid { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; }
|
||||||
|
.tcard-woid .id { font-weight: 800; font-size: 15px; color: var(--accent); }
|
||||||
|
.tcard-woid .age { font-size: 11px; color: var(--text-faint); }
|
||||||
|
.tcard-body { min-width: 0; }
|
||||||
|
.tcard-row1 { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: 4px; }
|
||||||
|
.tcard-customer { font-weight: 700; }
|
||||||
|
.tcard-loc { color: var(--text-muted); font-size: 13px; }
|
||||||
|
.tcard-topic { color: var(--text-muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.tcard-badges { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
|
||||||
|
.tcard-side { display: flex; align-items: center; gap: var(--sp-3); }
|
||||||
|
.tcard-detail { border-top: 1px solid var(--border); padding: var(--sp-4); background: var(--surface-1); }
|
||||||
|
.chevron { color: var(--text-muted); transition: transform 0.2s ease; }
|
||||||
|
.chevron.up { transform: rotate(180deg); }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.tcard-main { grid-template-columns: 64px 1fr; }
|
||||||
|
.tcard-side { grid-column: 1 / -1; justify-content: space-between; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Generic data list rows (Kunden/Finanzen/Dokumente) --- */
|
||||||
|
.list { display: flex; flex-direction: column; }
|
||||||
|
.list-row {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
|
padding: 12px var(--sp-4); border-bottom: 1px solid var(--border);
|
||||||
|
transition: background 0.12s ease; text-decoration: none; color: var(--text);
|
||||||
|
}
|
||||||
|
.list-row:last-child { border-bottom: none; }
|
||||||
|
.list-row:hover { background: var(--surface-2); }
|
||||||
|
.list-row .grow { flex: 1; min-width: 0; }
|
||||||
|
.list-row .muted { color: var(--text-muted); font-size: 13px; }
|
||||||
|
|
||||||
|
/* --- Clean table (ohne rowSpan-Tricks) --- */
|
||||||
|
.ui-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.ui-table th {
|
||||||
|
text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px;
|
||||||
|
color: var(--text-faint); font-weight: 600; padding: 10px var(--sp-3);
|
||||||
|
border-bottom: 1px solid var(--border); background: transparent;
|
||||||
|
}
|
||||||
|
.ui-table td { padding: 11px var(--sp-3); border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||||
|
.ui-table tbody tr:hover td { background: var(--surface-2); }
|
||||||
|
|
||||||
|
/* --- Segmented control / toolbar --- */
|
||||||
|
.toolbar { display: flex; gap: var(--sp-3); flex-wrap: wrap; align-items: center; margin-bottom: var(--sp-4); }
|
||||||
|
.seg { display: inline-flex; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 3px; gap: 2px; }
|
||||||
|
.seg button {
|
||||||
|
border: none; background: transparent; color: var(--text-muted);
|
||||||
|
padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.seg button.active { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.search-input { flex: 1; min-width: 200px; }
|
||||||
|
|
||||||
|
/* --- Empty state --- */
|
||||||
|
.empty { text-align: center; color: var(--text-muted); padding: var(--sp-6) var(--sp-4); }
|
||||||
|
.empty .empty-icon { font-size: 32px; opacity: 0.5; margin-bottom: var(--sp-3); }
|
||||||
|
|
||||||
|
/* --- Field --- */
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--sp-3); }
|
||||||
|
.field label { font-size: 13px; font-weight: 600; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* --- Tab restyle (ruhiger) --- */
|
||||||
|
.ticket-tabs { border-bottom-color: var(--border); }
|
||||||
|
.ticket-tab { background: var(--surface-2); border-color: var(--border); color: var(--text-muted); }
|
||||||
|
.ticket-tab:hover { background: var(--surface-hover); color: var(--text); }
|
||||||
|
.ticket-tab-active { background: var(--accent); color: #06231a; }
|
||||||
|
|
||||||
|
/* --- Soften legacy cards/forms --- */
|
||||||
|
.card { background: var(--surface-1) !important; border: 1px solid var(--border) !important; border-radius: var(--r-md); }
|
||||||
|
.card-header { background: var(--surface-2); border-bottom: 1px solid var(--border); }
|
||||||
|
.modern-card { background: var(--surface-1); border: 1px solid var(--border); }
|
||||||
|
.modern-card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-2); }
|
||||||
|
.form-control, .form-select { background: var(--surface-2) !important; border: 1px solid var(--border) !important; border-radius: var(--r-sm); }
|
||||||
|
.form-control:focus, .form-select:focus { border-color: var(--accent) !important; box-shadow: 0 0 0 3px var(--accent-soft) !important; }
|
||||||
|
.footer { background: var(--surface-1); border-top: 1px solid var(--border); padding: var(--sp-4); margin-top: 0; }
|
||||||
|
|
||||||
|
/* --- Utilities --- */
|
||||||
|
.muted { color: var(--text-muted); }
|
||||||
|
.faint { color: var(--text-faint); }
|
||||||
|
.flex { display: flex; }
|
||||||
|
.flex-col { display: flex; flex-direction: column; }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.gap-1 { gap: var(--sp-1); } .gap-2 { gap: var(--sp-2); } .gap-3 { gap: var(--sp-3); } .gap-4 { gap: var(--sp-4); }
|
||||||
|
.grow { flex: 1; min-width: 0; }
|
||||||
|
.wrap { flex-wrap: wrap; }
|
||||||
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
||||||
|
.text-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.automatic-assignment-note {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.automatic-assignment-note p {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,38 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [react()],
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
// Direkt zu Appwrite (ein Hop weniger als ticket.webklar.com → nginx → Appwrite)
|
||||||
|
const apiTarget =
|
||||||
|
env.VITE_APPWRITE_PROXY_TARGET || 'https://appwrite.webklar.com'
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/v1': {
|
||||||
|
target: apiTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
timeout: 120_000,
|
||||||
|
proxyTimeout: 120_000,
|
||||||
|
cookieDomainRewrite: 'localhost',
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on('error', (err, req, res) => {
|
||||||
|
console.error('[vite proxy /v1]', err.message)
|
||||||
|
if (res && !res.headersSent) {
|
||||||
|
res.writeHead(502, { 'Content-Type': 'application/json' })
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
message: `Proxy-Fehler: ${err.message}. Ist ${apiTarget} erreichbar?`,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user