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
|
||||
node_modules/
|
||||
|
||||
# Environment variables
|
||||
# Environment variables (persönliche Overrides — nicht committen)
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.*.local
|
||||
|
||||
# Build output
|
||||
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",
|
||||
"configHash": "6a220e5a",
|
||||
"lockfileHash": "a10f8d29",
|
||||
"browserHash": "db384cdc",
|
||||
"hash": "91e90cf6",
|
||||
"configHash": "eaf678ca",
|
||||
"lockfileHash": "4b2b8d7d",
|
||||
"browserHash": "114e306f",
|
||||
"optimized": {
|
||||
"react": {
|
||||
"src": "../../react/index.js",
|
||||
"file": "react.js",
|
||||
"fileHash": "d41cd819",
|
||||
"fileHash": "c1394b31",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react-dom": {
|
||||
"src": "../../react-dom/index.js",
|
||||
"file": "react-dom.js",
|
||||
"fileHash": "02c7f745",
|
||||
"fileHash": "e0cfaa25",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react/jsx-dev-runtime": {
|
||||
"src": "../../react/jsx-dev-runtime.js",
|
||||
"file": "react_jsx-dev-runtime.js",
|
||||
"fileHash": "beec6a15",
|
||||
"fileHash": "4e6552af",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react/jsx-runtime": {
|
||||
"src": "../../react/jsx-runtime.js",
|
||||
"file": "react_jsx-runtime.js",
|
||||
"fileHash": "f6c3e40e",
|
||||
"fileHash": "326b9f6e",
|
||||
"needsInterop": true
|
||||
},
|
||||
"@tabler/icons-react": {
|
||||
"src": "../../@tabler/icons-react/dist/esm/tabler-icons-react.mjs",
|
||||
"file": "@tabler_icons-react.js",
|
||||
"fileHash": "11d925b5",
|
||||
"fileHash": "ef17992e",
|
||||
"needsInterop": false
|
||||
},
|
||||
"appwrite": {
|
||||
"src": "../../appwrite/dist/esm/sdk.js",
|
||||
"file": "appwrite.js",
|
||||
"fileHash": "e5052173",
|
||||
"fileHash": "e3207492",
|
||||
"needsInterop": false
|
||||
},
|
||||
"clsx": {
|
||||
"src": "../../clsx/dist/clsx.mjs",
|
||||
"file": "clsx.js",
|
||||
"fileHash": "7fc4f217",
|
||||
"fileHash": "438277fe",
|
||||
"needsInterop": false
|
||||
},
|
||||
"date-fns": {
|
||||
"src": "../../date-fns/esm/index.js",
|
||||
"file": "date-fns.js",
|
||||
"fileHash": "371588f0",
|
||||
"fileHash": "c8cdb3c6",
|
||||
"needsInterop": false
|
||||
},
|
||||
"date-fns/locale": {
|
||||
"src": "../../date-fns/esm/locale/index.js",
|
||||
"file": "date-fns_locale.js",
|
||||
"fileHash": "927376cc",
|
||||
"fileHash": "affdbcf7",
|
||||
"needsInterop": false
|
||||
},
|
||||
"motion/react": {
|
||||
"src": "../../motion/dist/es/react.mjs",
|
||||
"file": "motion_react.js",
|
||||
"fileHash": "03c47f11",
|
||||
"fileHash": "d06adb28",
|
||||
"needsInterop": false
|
||||
},
|
||||
"postprocessing": {
|
||||
"src": "../../postprocessing/build/index.js",
|
||||
"file": "postprocessing.js",
|
||||
"fileHash": "6a1af3d4",
|
||||
"fileHash": "a05d80d0",
|
||||
"needsInterop": false
|
||||
},
|
||||
"react-dom/client": {
|
||||
"src": "../../react-dom/client.js",
|
||||
"file": "react-dom_client.js",
|
||||
"fileHash": "13d89711",
|
||||
"fileHash": "35e13db7",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react-icons/fa": {
|
||||
"src": "../../react-icons/fa/index.esm.js",
|
||||
"file": "react-icons_fa.js",
|
||||
"fileHash": "ce6891d0",
|
||||
"fileHash": "b39d751a",
|
||||
"needsInterop": false
|
||||
},
|
||||
"react-icons/fa6": {
|
||||
"src": "../../react-icons/fa6/index.esm.js",
|
||||
"file": "react-icons_fa6.js",
|
||||
"fileHash": "836a5ede",
|
||||
"fileHash": "ee91d98a",
|
||||
"needsInterop": false
|
||||
},
|
||||
"react-router-dom": {
|
||||
"src": "../../react-router-dom/dist/index.js",
|
||||
"file": "react-router-dom.js",
|
||||
"fileHash": "65b7bd91",
|
||||
"fileHash": "96b74880",
|
||||
"needsInterop": false
|
||||
},
|
||||
"tailwind-merge": {
|
||||
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
|
||||
"file": "tailwind-merge.js",
|
||||
"fileHash": "8ea254d4",
|
||||
"fileHash": "422eebfe",
|
||||
"needsInterop": false
|
||||
},
|
||||
"three": {
|
||||
"src": "../../three/build/three.module.js",
|
||||
"file": "three.js",
|
||||
"fileHash": "c500eef6",
|
||||
"fileHash": "7ea78180",
|
||||
"needsInterop": false
|
||||
}
|
||||
},
|
||||
@@ -111,20 +111,20 @@
|
||||
"chunk-IFCYBMKG": {
|
||||
"file": "chunk-IFCYBMKG.js"
|
||||
},
|
||||
"chunk-7VTGDDTZ": {
|
||||
"file": "chunk-7VTGDDTZ.js"
|
||||
"chunk-DQJKJRV5": {
|
||||
"file": "chunk-DQJKJRV5.js"
|
||||
},
|
||||
"chunk-TDH2IRYZ": {
|
||||
"file": "chunk-TDH2IRYZ.js"
|
||||
"chunk-PJEEZAML": {
|
||||
"file": "chunk-PJEEZAML.js"
|
||||
},
|
||||
"chunk-NMLHVZ76": {
|
||||
"file": "chunk-NMLHVZ76.js"
|
||||
"chunk-6PXSGDAH": {
|
||||
"file": "chunk-6PXSGDAH.js"
|
||||
},
|
||||
"chunk-QRULMDK5": {
|
||||
"file": "chunk-QRULMDK5.js"
|
||||
"chunk-DRWLMN53": {
|
||||
"file": "chunk-DRWLMN53.js"
|
||||
},
|
||||
"chunk-FSI7PPCM": {
|
||||
"file": "chunk-FSI7PPCM.js"
|
||||
"chunk-SJKHQ62W": {
|
||||
"file": "chunk-SJKHQ62W.js"
|
||||
},
|
||||
"chunk-G3PMV62Z": {
|
||||
"file": "chunk-G3PMV62Z.js"
|
||||
|
||||
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,
|
||||
yearsToMonths,
|
||||
yearsToQuarters
|
||||
} from "./chunk-FSI7PPCM.js";
|
||||
} from "./chunk-SJKHQ62W.js";
|
||||
import "./chunk-G3PMV62Z.js";
|
||||
export {
|
||||
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,
|
||||
startOfUTCWeek,
|
||||
toDate
|
||||
} from "./chunk-FSI7PPCM.js";
|
||||
} from "./chunk-SJKHQ62W.js";
|
||||
import "./chunk-G3PMV62Z.js";
|
||||
|
||||
// node_modules/date-fns/esm/locale/af/_lib/formatDistance/index.js
|
||||
|
||||
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 {
|
||||
require_react_dom
|
||||
} from "./chunk-TDH2IRYZ.js";
|
||||
import "./chunk-QRULMDK5.js";
|
||||
} from "./chunk-PJEEZAML.js";
|
||||
import "./chunk-DRWLMN53.js";
|
||||
import "./chunk-G3PMV62Z.js";
|
||||
export default require_react_dom();
|
||||
//# sourceMappingURL=react-dom.js.map
|
||||
|
||||
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 {
|
||||
require_react_dom
|
||||
} from "./chunk-TDH2IRYZ.js";
|
||||
import "./chunk-QRULMDK5.js";
|
||||
} from "./chunk-PJEEZAML.js";
|
||||
import "./chunk-DRWLMN53.js";
|
||||
import {
|
||||
__commonJS
|
||||
} from "./chunk-G3PMV62Z.js";
|
||||
|
||||
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,
|
||||
"sources": ["../../react-dom/client.js"],
|
||||
"sourcesContent": ["'use strict';\r\n\r\nvar m = require('react-dom');\r\nif (process.env.NODE_ENV === 'production') {\r\n exports.createRoot = m.createRoot;\r\n exports.hydrateRoot = m.hydrateRoot;\r\n} else {\r\n var i = m.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;\r\n exports.createRoot = function(c, o) {\r\n i.usingClientEntryPoint = true;\r\n try {\r\n return m.createRoot(c, o);\r\n } finally {\r\n i.usingClientEntryPoint = false;\r\n }\r\n };\r\n exports.hydrateRoot = function(c, h, o) {\r\n i.usingClientEntryPoint = true;\r\n try {\r\n return m.hydrateRoot(c, h, o);\r\n } finally {\r\n i.usingClientEntryPoint = false;\r\n }\r\n };\r\n}\r\n"],
|
||||
"sourcesContent": ["'use strict';\n\nvar m = require('react-dom');\nif (process.env.NODE_ENV === 'production') {\n exports.createRoot = m.createRoot;\n exports.hydrateRoot = m.hydrateRoot;\n} else {\n var i = m.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;\n exports.createRoot = function(c, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.createRoot(c, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n exports.hydrateRoot = function(c, h, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.hydrateRoot(c, h, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n}\n"],
|
||||
"mappings": ";;;;;;;;;AAAA;AAAA;AAEA,QAAI,IAAI;AACR,QAAI,OAAuC;AACzC,cAAQ,aAAa,EAAE;AACvB,cAAQ,cAAc,EAAE;AAAA,IAC1B,OAAO;AACD,UAAI,EAAE;AACV,cAAQ,aAAa,SAAS,GAAG,GAAG;AAClC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,WAAW,GAAG,CAAC;AAAA,QAC1B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AACA,cAAQ,cAAc,SAAS,GAAG,GAAG,GAAG;AACtC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,YAAY,GAAG,GAAG,CAAC;AAAA,QAC9B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AAAA,IACF;AAjBM;AAAA;AAAA;",
|
||||
"names": []
|
||||
}
|
||||
|
||||
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 {
|
||||
GenIcon
|
||||
} from "./chunk-7VTGDDTZ.js";
|
||||
import "./chunk-QRULMDK5.js";
|
||||
} from "./chunk-DQJKJRV5.js";
|
||||
import "./chunk-DRWLMN53.js";
|
||||
import "./chunk-G3PMV62Z.js";
|
||||
|
||||
// node_modules/react-icons/fa/index.esm.js
|
||||
|
||||
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 {
|
||||
GenIcon
|
||||
} from "./chunk-7VTGDDTZ.js";
|
||||
import "./chunk-QRULMDK5.js";
|
||||
} from "./chunk-DQJKJRV5.js";
|
||||
import "./chunk-DRWLMN53.js";
|
||||
import "./chunk-G3PMV62Z.js";
|
||||
|
||||
// node_modules/react-icons/fa6/index.esm.js
|
||||
|
||||
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 {
|
||||
require_react_dom
|
||||
} from "./chunk-TDH2IRYZ.js";
|
||||
} from "./chunk-PJEEZAML.js";
|
||||
import {
|
||||
require_react
|
||||
} from "./chunk-QRULMDK5.js";
|
||||
} from "./chunk-DRWLMN53.js";
|
||||
import {
|
||||
__toESM
|
||||
} from "./chunk-G3PMV62Z.js";
|
||||
|
||||
2
node_modules/.vite/deps/react.js
generated
vendored
2
node_modules/.vite/deps/react.js
generated
vendored
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
require_react
|
||||
} from "./chunk-QRULMDK5.js";
|
||||
} from "./chunk-DRWLMN53.js";
|
||||
import "./chunk-G3PMV62Z.js";
|
||||
export default require_react();
|
||||
//# sourceMappingURL=react.js.map
|
||||
|
||||
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 {
|
||||
require_react
|
||||
} from "./chunk-QRULMDK5.js";
|
||||
} from "./chunk-DRWLMN53.js";
|
||||
import {
|
||||
__commonJS
|
||||
} 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 {
|
||||
require_jsx_runtime
|
||||
} from "./chunk-NMLHVZ76.js";
|
||||
import "./chunk-QRULMDK5.js";
|
||||
} from "./chunk-6PXSGDAH.js";
|
||||
import "./chunk-DRWLMN53.js";
|
||||
import "./chunk-G3PMV62Z.js";
|
||||
export default require_jsx_runtime();
|
||||
//# sourceMappingURL=react_jsx-runtime.js.map
|
||||
|
||||
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-dom": "^18.2.0",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"three": "^0.182.0"
|
||||
|
||||
102
src/App.jsx
102
src/App.jsx
@@ -1,49 +1,62 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import Navbar from './components/Navbar'
|
||||
import Sidebar from './components/Navbar'
|
||||
import Footer from './components/Footer'
|
||||
import PixelBlast from './components/PixelBlast'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import TicketsPage from './pages/TicketsPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import AssetsPage from './pages/AssetsPage'
|
||||
import PlanboardPage from './pages/PlanboardPage'
|
||||
import ProjectsPage from './pages/ProjectsPage'
|
||||
import RoadmapsPage from './pages/RoadmapsPage'
|
||||
import RoadmapDetailPage from './pages/RoadmapDetailPage'
|
||||
import ReportsPage from './pages/ReportsPage'
|
||||
import DocsPage from './pages/DocsPage'
|
||||
import AdminPage from './pages/AdminPage'
|
||||
import CustomersPage from './pages/CustomersPage'
|
||||
import LeadsPage from './pages/LeadsPage'
|
||||
import CustomerDetailPage from './pages/CustomerDetailPage'
|
||||
import FinancePage from './pages/FinancePage'
|
||||
import DocumentsPage from './pages/DocumentsPage'
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center p-4">
|
||||
<div className="text-center p-4" style={{ marginTop: 80 }}>
|
||||
<div className="spinner"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { user } = useAuth()
|
||||
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={user ? <Navigate to="/tickets" replace /> : <LoginPage />} />
|
||||
<Route path="/" element={<Navigate to="/tickets" replace />} />
|
||||
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
|
||||
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
|
||||
<Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/leads" element={<ProtectedRoute><LeadsPage /></ProtectedRoute>} />
|
||||
<Route path="/finance" element={<ProtectedRoute><FinancePage /></ProtectedRoute>} />
|
||||
<Route path="/documents" element={<ProtectedRoute><DocumentsPage /></ProtectedRoute>} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
|
||||
<Route path="/assets" element={<ProtectedRoute><AssetsPage /></ProtectedRoute>} />
|
||||
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
|
||||
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></ProtectedRoute>} />
|
||||
<Route path="/roadmaps" element={<ProtectedRoute><RoadmapsPage /></ProtectedRoute>} />
|
||||
<Route path="/roadmaps/:id" element={<ProtectedRoute><RoadmapDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/reports" element={<ProtectedRoute><ReportsPage /></ProtectedRoute>} />
|
||||
<Route path="/docs" element={<ProtectedRoute><DocsPage /></ProtectedRoute>} />
|
||||
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
|
||||
@@ -53,74 +66,21 @@ function AppRoutes() {
|
||||
|
||||
function AppContent() {
|
||||
const { user } = useAuth()
|
||||
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<>
|
||||
{/* PixelBlast Background für Login-Seite */}
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
zIndex: 0,
|
||||
background: '#1a202c'
|
||||
}}>
|
||||
<PixelBlast
|
||||
color="#10b981"
|
||||
variant="circle"
|
||||
pixelSize={4}
|
||||
patternScale={2}
|
||||
patternDensity={0.8}
|
||||
enableRipples={true}
|
||||
rippleIntensityScale={1.5}
|
||||
speed={0.3}
|
||||
edgeFade={0.3}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
return <AppRoutes />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Sidebar />
|
||||
<div className="app-main">
|
||||
<div className="app-scroll">
|
||||
<AppRoutes />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* PixelBlast Background für Haupt-App */}
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
zIndex: 0,
|
||||
background: '#1a202c'
|
||||
}}>
|
||||
<PixelBlast
|
||||
color="#10b981"
|
||||
variant="circle"
|
||||
pixelSize={4}
|
||||
patternScale={2}
|
||||
patternDensity={0.8}
|
||||
enableRipples={true}
|
||||
rippleIntensityScale={1.5}
|
||||
speed={0.3}
|
||||
edgeFade={0.3}
|
||||
/>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
||||
<Navbar />
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<AppRoutes />
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
97
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
|
||||
const DEFAULT_TICKET_TYPES = [
|
||||
'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request',
|
||||
'Maintenance', 'Project', 'Controlling', 'Development', 'Documentation',
|
||||
'Akquise', 'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request',
|
||||
'Maintenance', 'Project', 'Webpage', 'Controlling', 'Development', 'Documentation',
|
||||
'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
|
||||
'Rollout', 'Emergency Call', 'Other Services'
|
||||
]
|
||||
@@ -55,8 +55,6 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
||||
topic: '',
|
||||
requestedBy: '',
|
||||
requestedFor: '',
|
||||
assignedTo: '', // Zugewiesener Mitarbeiter (User ID)
|
||||
status: 'Open', // Status wird automatisch gesetzt basierend auf assignedTo
|
||||
startDate: today,
|
||||
startTime: '',
|
||||
deadline: today,
|
||||
@@ -235,30 +233,13 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Assigned To</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={formData.assignedTo}
|
||||
onChange={(e) => {
|
||||
const userId = e.target.value
|
||||
handleChange('assignedTo', userId)
|
||||
// Status-Automatik: Wenn Mitarbeiter zugewiesen → Status = "Assigned"
|
||||
// Wenn kein Mitarbeiter → Status = "Open"
|
||||
if (userId) {
|
||||
handleChange('status', 'Assigned')
|
||||
} else {
|
||||
handleChange('status', 'Open')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{employees.map(emp => (
|
||||
<option key={emp.$id} value={emp.userId}>
|
||||
{emp.displayName}{emp.shortcode ? ` (${emp.shortcode})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="form-group automatic-assignment-note">
|
||||
<label className="form-label">Zuweisung</label>
|
||||
<p>
|
||||
{employees.length > 0
|
||||
? `Automatisch an den Mitarbeiter mit der geringsten Anzahl offener Tickets (${employees.length} verfügbar).`
|
||||
: 'Es ist noch kein Mitarbeiter verfügbar. Das Ticket bleibt zunächst offen.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ const RESPONSE_LEVELS = [
|
||||
'n/a'
|
||||
]
|
||||
|
||||
// Akquise-Tickets: Ergebnis des Kundenkontakts (statt technischer Status)
|
||||
const ACQUISITION_STATUS = ['In Kontakt', 'Zugesagt', 'Abgesagt']
|
||||
|
||||
export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate }) {
|
||||
const { user } = useAuth()
|
||||
const today = new Date().toLocaleDateString('de-DE', {
|
||||
@@ -55,12 +58,18 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
||||
const [error, setError] = useState('')
|
||||
const [autoCalculate, setAutoCalculate] = useState(true)
|
||||
|
||||
const isAcquisition = workorder?.type === 'Akquise'
|
||||
const statusOptions = isAcquisition ? ACQUISITION_STATUS : STATUS_OPTIONS
|
||||
const defaultStatus = isAcquisition
|
||||
? (ACQUISITION_STATUS.includes(workorder?.status) ? workorder.status : 'In Kontakt')
|
||||
: (workorder?.status || 'Open')
|
||||
|
||||
// Reset form wenn Modal geöffnet wird
|
||||
useEffect(() => {
|
||||
if (isOpen && workorder) {
|
||||
setFormData({
|
||||
serviceType: 'Remote',
|
||||
newStatus: workorder.status || 'Open',
|
||||
newStatus: defaultStatus,
|
||||
newResponseLevel: workorder.responseLevel || '',
|
||||
totalTime: 0,
|
||||
startDate: today,
|
||||
@@ -189,17 +198,22 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">New Status</label>
|
||||
<label className="form-label">{isAcquisition ? 'Akquise-Ergebnis' : 'New Status'}</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={formData.newStatus}
|
||||
onChange={(e) => handleChange('newStatus', e.target.value)}
|
||||
required
|
||||
>
|
||||
{STATUS_OPTIONS.map(status => (
|
||||
{statusOptions.map(status => (
|
||||
<option key={status} value={status}>{status}</option>
|
||||
))}
|
||||
</select>
|
||||
{isAcquisition && (
|
||||
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||
"Zugesagt" macht den Lead automatisch zum festen Kunden.
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
|
||||
158
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 { FaTimes, FaSpinner } from 'react-icons/fa'
|
||||
import { storage, BUCKET_ID, ID } from '../lib/appwrite'
|
||||
import { integrationsApi } from '../lib/integrationsApi'
|
||||
|
||||
export default function FileUploadModal({ isOpen, onClose, workorderId, onUploadComplete }) {
|
||||
export default function FileUploadModal({ isOpen, onClose, workorderId, ticket, onUploadComplete }) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [status, setStatus] = useState('')
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
const handleDrop = async (e) => {
|
||||
@@ -33,14 +35,39 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
|
||||
setStatus('Lade in Ablage hoch...')
|
||||
|
||||
try {
|
||||
const response = await storage.createFile(
|
||||
BUCKET_ID,
|
||||
ID.unique(),
|
||||
file
|
||||
)
|
||||
|
||||
|
||||
// Automatisch nach Paperless-ngx schieben (wichtige Kundendokumente)
|
||||
const woid = ticket?.woid || workorderId
|
||||
const correspondent = ticket?.customerName || undefined
|
||||
if (correspondent || woid) {
|
||||
setStatus('Synchronisiere mit Paperless...')
|
||||
const tags = []
|
||||
if (correspondent) tags.push(correspondent)
|
||||
if (woid) tags.push(`WOID-${woid}`)
|
||||
tags.push('Ticket')
|
||||
try {
|
||||
await integrationsApi.uploadToPaperless({
|
||||
bucketId: BUCKET_ID,
|
||||
fileId: response.$id,
|
||||
filename: file.name,
|
||||
title: `${woid ? `[${woid}] ` : ''}${file.name}`,
|
||||
correspondent,
|
||||
tags,
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('Paperless-Sync fehlgeschlagen:', e.message)
|
||||
// Upload in Appwrite ist trotzdem erfolgt - nicht hart abbrechen
|
||||
}
|
||||
}
|
||||
|
||||
onUploadComplete?.(response)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
@@ -48,6 +75,7 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
|
||||
alert('Error uploading file: ' + error.message)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setStatus('')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +100,7 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
|
||||
{uploading ? (
|
||||
<div>
|
||||
<FaSpinner className="spinner" size={32} />
|
||||
<p className="mt-1">Uploading file...</p>
|
||||
<p className="mt-1">{status || 'Uploading file...'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<p>
|
||||
WOMS 2.0 - Work Order Management System |
|
||||
Powered by <a href="https://appwrite.io" target="_blank" rel="noopener noreferrer">Appwrite</a> & React
|
||||
</p>
|
||||
<p className="text-small text-grey">
|
||||
© {new Date().getFullYear()} - All rights reserved
|
||||
<p className="faint" style={{ fontSize: 13, margin: 0 }}>
|
||||
Webklar Ticket-Plattform · © {new Date().getFullYear()}
|
||||
</p>
|
||||
</footer>
|
||||
)
|
||||
|
||||
30
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 { Link, useNavigate } from 'react-router-dom'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { motion } from 'motion/react'
|
||||
import {
|
||||
IconTicket,
|
||||
IconDeviceDesktop,
|
||||
IconChartBar,
|
||||
IconCalendar,
|
||||
IconFolders,
|
||||
IconFileText,
|
||||
IconBook,
|
||||
import {
|
||||
IconTicket,
|
||||
IconUsers,
|
||||
IconTargetArrow,
|
||||
IconReportMoney,
|
||||
IconFiles,
|
||||
IconFolders,
|
||||
IconCalendar,
|
||||
IconRoute,
|
||||
IconLayoutDashboard,
|
||||
IconReport,
|
||||
IconDeviceDesktop,
|
||||
IconSettings,
|
||||
IconLogout
|
||||
IconBook,
|
||||
IconLogout,
|
||||
IconLayoutSidebarLeftCollapse,
|
||||
IconLayoutSidebarLeftExpand,
|
||||
} from '@tabler/icons-react'
|
||||
import { Sidebar, SidebarBody, SidebarLink } from './ModernSidebar'
|
||||
|
||||
export default function Navbar() {
|
||||
export default function Sidebar() {
|
||||
const { user, logout, isAdmin } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [open, setOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const links = [
|
||||
const groups = [
|
||||
{
|
||||
label: 'Tickets',
|
||||
href: '/tickets',
|
||||
icon: <IconTicket className="icon" />,
|
||||
label: 'Betrieb',
|
||||
links: [
|
||||
{ label: 'Tickets', href: '/tickets', icon: <IconTicket /> },
|
||||
{ label: 'Planboard', href: '/planboard', icon: <IconCalendar /> },
|
||||
{ label: 'Roadmaps', href: '/roadmaps', icon: <IconRoute /> },
|
||||
{ label: 'Projekte', href: '/projects', icon: <IconFolders /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Assets',
|
||||
href: '/assets',
|
||||
icon: <IconDeviceDesktop className="icon" />,
|
||||
label: 'CRM',
|
||||
links: [
|
||||
{ label: 'Kunden', href: '/customers', icon: <IconUsers /> },
|
||||
{ label: 'Leads', href: '/leads', icon: <IconTargetArrow /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Dashboard',
|
||||
href: '/dashboard',
|
||||
icon: <IconChartBar className="icon" />,
|
||||
label: 'Finanzen',
|
||||
links: [{ label: 'Finanzen', href: '/finance', icon: <IconReportMoney /> }],
|
||||
},
|
||||
{
|
||||
label: 'Planboard',
|
||||
href: '/planboard',
|
||||
icon: <IconCalendar className="icon" />,
|
||||
label: 'Dokumente',
|
||||
links: [{ label: 'Dokumente', href: '/documents', icon: <IconFiles /> }],
|
||||
},
|
||||
{
|
||||
label: 'Projects',
|
||||
href: '/projects',
|
||||
icon: <IconFolders className="icon" />,
|
||||
label: 'Auswertung',
|
||||
links: [
|
||||
{ label: 'Dashboard', href: '/dashboard', icon: <IconLayoutDashboard /> },
|
||||
{ label: 'Reports', href: '/reports', icon: <IconReport /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Reports',
|
||||
href: '/reports',
|
||||
icon: <IconFileText className="icon" />,
|
||||
},
|
||||
{
|
||||
label: 'Docs',
|
||||
href: '/docs',
|
||||
icon: <IconBook className="icon" />,
|
||||
label: 'System',
|
||||
links: [
|
||||
{ label: 'Assets', href: '/assets', icon: <IconDeviceDesktop /> },
|
||||
...(isAdmin ? [{ label: 'Admin', href: '/admin', icon: <IconSettings /> }] : []),
|
||||
{ label: 'Docs', href: '/docs', icon: <IconBook /> },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// Admin link nur wenn isAdmin
|
||||
if (isAdmin) {
|
||||
links.push({
|
||||
label: 'Admin',
|
||||
href: '/admin',
|
||||
icon: <IconSettings className="icon" />,
|
||||
})
|
||||
}
|
||||
const isActive = (href) =>
|
||||
location.pathname === href || location.pathname.startsWith(href + '/')
|
||||
|
||||
return (
|
||||
<Sidebar open={open} setOpen={setOpen}>
|
||||
<SidebarBody className="justify-between gap-10">
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflowX: 'hidden', overflowY: 'auto' }}>
|
||||
<Logo />
|
||||
<div style={{ marginTop: '2rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{links.map((link, idx) => (
|
||||
<SidebarLink key={idx} link={link} />
|
||||
<aside className={`side ${collapsed ? 'collapsed' : ''}`}>
|
||||
<div className="side-head">
|
||||
<Link to="/" className="side-logo" title="Webklar">W</Link>
|
||||
<div className="side-brand">
|
||||
Webklar
|
||||
<small>Ticket-Plattform</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="side-nav">
|
||||
{groups.map((group) => (
|
||||
<div className="side-group" key={group.label}>
|
||||
<div className="side-group-label">{group.label}</div>
|
||||
{group.links.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
to={link.href}
|
||||
className={`side-link ${isActive(link.href) ? 'active' : ''}`}
|
||||
title={link.label}
|
||||
>
|
||||
{link.icon}
|
||||
<span className="side-link-label">{link.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{user ? (
|
||||
<>
|
||||
<SidebarLink
|
||||
link={{
|
||||
label: user.name || user.email,
|
||||
href: '#',
|
||||
icon: (
|
||||
<div style={{
|
||||
height: '28px',
|
||||
width: '28px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{(user.name || user.email).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<div onClick={handleLogout} style={{ cursor: 'pointer', marginTop: '0.5rem' }}>
|
||||
<SidebarLink
|
||||
link={{
|
||||
label: 'Logout',
|
||||
href: '#',
|
||||
icon: <IconLogout className="icon" />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<SidebarLink
|
||||
link={{
|
||||
label: 'Login',
|
||||
href: '/login',
|
||||
icon: <IconLogout className="icon" />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SidebarBody>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
))}
|
||||
</nav>
|
||||
|
||||
const Logo = () => {
|
||||
return (
|
||||
<Link
|
||||
to="/"
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.25rem 0',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'normal',
|
||||
color: 'var(--text-color, #333)',
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
height: '20px',
|
||||
width: '24px',
|
||||
flexShrink: 0,
|
||||
borderRadius: '4px 2px 4px 2px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
}} />
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'pre',
|
||||
color: 'var(--text-color, #333)'
|
||||
}}
|
||||
>
|
||||
Webklar
|
||||
</motion.span>
|
||||
</Link>
|
||||
<div className="side-foot">
|
||||
{user && (
|
||||
<div className="side-link" title={user.name || user.email} style={{ cursor: 'default' }}>
|
||||
<div className="side-logo" style={{ width: 28, height: 28, fontSize: 12, background: 'linear-gradient(135deg,#667eea,#764ba2)' }}>
|
||||
{(user.name || user.email || '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="side-link-label text-ellipsis">{user.name || user.email}</span>
|
||||
</div>
|
||||
)}
|
||||
<button className="side-toggle" onClick={handleLogout} title="Logout">
|
||||
<IconLogout size={20} />
|
||||
<span className="side-link-label">Logout</span>
|
||||
</button>
|
||||
<button className="side-toggle" onClick={() => setCollapsed((c) => !c)} title="Menue ein-/ausklappen">
|
||||
{collapsed ? <IconLayoutSidebarLeftExpand size={20} /> : <IconLayoutSidebarLeftCollapse size={20} />}
|
||||
<span className="side-link-label">Einklappen</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
30
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 { FaLock, FaLockOpen, FaPlay, FaStop, FaTruck, FaSackDollar, FaUserGear, FaPlus, FaClockRotateLeft } from 'react-icons/fa6'
|
||||
import { FaLock, FaLockOpen, FaPlay, FaStop, FaTruck, FaSackDollar, FaUserGear, FaPlus, FaClockRotateLeft, FaPen } from 'react-icons/fa6'
|
||||
import { formatDistanceToNow, format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import StatusDropdown from './StatusDropdown'
|
||||
@@ -7,9 +7,14 @@ import PriorityDropdown from './PriorityDropdown'
|
||||
import EditorDropdown from './EditorDropdown'
|
||||
import ResponseDropdown from './ResponseDropdown'
|
||||
import CreateWorksheetModal from './CreateWorksheetModal'
|
||||
import EditWorksheetTimeModal from './EditWorksheetTimeModal'
|
||||
import StatusHistoryModal from './StatusHistoryModal'
|
||||
import WorksheetList from './WorksheetList'
|
||||
import WorksheetStats from './WorksheetStats'
|
||||
import TicketAssignedProjectsPanel from './TicketAssignedProjectsPanel'
|
||||
import TicketProjectsModal from './TicketProjectsModal'
|
||||
import InvoicePanel from './InvoicePanel'
|
||||
import DocumentsPanel from './DocumentsPanel'
|
||||
import { useWorksheets } from '../hooks/useWorksheets'
|
||||
|
||||
const PRIORITY_CLASSES = {
|
||||
@@ -20,14 +25,6 @@ const PRIORITY_CLASSES = {
|
||||
4: 'priority-critical'
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS = {
|
||||
0: 'NONE',
|
||||
1: 'LOW',
|
||||
2: 'MEDIUM',
|
||||
3: 'HIGH',
|
||||
4: 'CRITICAL'
|
||||
}
|
||||
|
||||
const STATUS_CLASSES = {
|
||||
'Open': 'status-open',
|
||||
'Occupied': 'status-occupied',
|
||||
@@ -43,38 +40,42 @@ const APPROVAL_ICONS = {
|
||||
'shipping': FaTruck
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: 'details', label: 'Details' },
|
||||
{ id: 'worksheets', label: 'Arbeitsblaetter' },
|
||||
{ id: 'invoices', label: 'Rechnungen' },
|
||||
{ id: 'documents', label: 'Dokumente' },
|
||||
{ id: 'project', label: 'Projekt / Preview' },
|
||||
]
|
||||
|
||||
export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [locked, setLocked] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
|
||||
const [editTimeWorksheet, setEditTimeWorksheet] = useState(null)
|
||||
const [showHistoryModal, setShowHistoryModal] = useState(false)
|
||||
|
||||
// Worksheets für dieses Ticket laden (nur wenn expanded)
|
||||
const {
|
||||
worksheets,
|
||||
loading: worksheetsLoading,
|
||||
const [showProjectsModal, setShowProjectsModal] = useState(false)
|
||||
const [projectsRefreshKey, setProjectsRefreshKey] = useState(0)
|
||||
|
||||
const {
|
||||
worksheets,
|
||||
loading: worksheetsLoading,
|
||||
createWorksheet,
|
||||
getTotalTime
|
||||
updateWorksheet,
|
||||
getTotalTime
|
||||
} = useWorksheets(expanded ? ticket.woid : null)
|
||||
|
||||
// Startzeit fuer Git-Push-Worksheets nachtragen (Endzeit kam vom Push)
|
||||
const handleSaveWorksheetTime = async (ws, data) => updateWorksheet(ws.$id, data)
|
||||
|
||||
const createdAt = new Date(ticket.$createdAt || ticket.createdAt)
|
||||
const elapsed = formatDistanceToNow(createdAt, { locale: de })
|
||||
|
||||
const handleStatusChange = (newStatus) => {
|
||||
onUpdate(ticket.$id, { status: newStatus })
|
||||
}
|
||||
|
||||
const handlePriorityChange = (newPriority) => {
|
||||
onUpdate(ticket.$id, { priority: newPriority })
|
||||
}
|
||||
|
||||
const handleEditorChange = (newEditor) => {
|
||||
onUpdate(ticket.$id, { assignedTo: newEditor })
|
||||
}
|
||||
|
||||
const handleResponseChange = (newResponse) => {
|
||||
onUpdate(ticket.$id, { responseLevel: newResponse })
|
||||
}
|
||||
const handleStatusChange = (newStatus) => onUpdate(ticket.$id, { status: newStatus })
|
||||
const handlePriorityChange = (newPriority) => onUpdate(ticket.$id, { priority: newPriority })
|
||||
const handleEditorChange = (newEditor) => onUpdate(ticket.$id, { assignedTo: newEditor })
|
||||
const handleResponseChange = (newResponse) => onUpdate(ticket.$id, { responseLevel: newResponse })
|
||||
|
||||
const toggleLock = () => {
|
||||
if (locked) {
|
||||
@@ -88,15 +89,12 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
||||
|
||||
const handleCreateWorksheet = async (worksheetData, currentUser) => {
|
||||
const result = await createWorksheet(worksheetData, currentUser)
|
||||
|
||||
// Wenn Status geändert wurde, aktualisiere Work Order
|
||||
if (result.success && worksheetData.newStatus !== ticket.status) {
|
||||
await onUpdate(ticket.$id, {
|
||||
await onUpdate(ticket.$id, {
|
||||
status: worksheetData.newStatus,
|
||||
responseLevel: worksheetData.newResponseLevel || ticket.responseLevel
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -136,25 +134,19 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
||||
{ticket.topic}
|
||||
</td>
|
||||
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
|
||||
<StatusDropdown
|
||||
value={ticket.status}
|
||||
onChange={handleStatusChange}
|
||||
/>
|
||||
<StatusDropdown value={ticket.status} onChange={handleStatusChange} />
|
||||
</td>
|
||||
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
|
||||
<PriorityDropdown
|
||||
value={ticket.priority}
|
||||
onChange={handlePriorityChange}
|
||||
/>
|
||||
<PriorityDropdown value={ticket.priority} onChange={handlePriorityChange} />
|
||||
</td>
|
||||
<td
|
||||
<td
|
||||
className={`text-center ${ticket.approvalStatus === 'approved' ? 'bg-green' : 'bg-yellow'}`}
|
||||
rowSpan={2}
|
||||
style={{ verticalAlign: 'middle' }}
|
||||
>
|
||||
<ApprovalIcon size={24} />
|
||||
</td>
|
||||
<td
|
||||
<td
|
||||
className="bg-dark-grey text-center text-white"
|
||||
rowSpan={2}
|
||||
style={{ verticalAlign: 'middle', cursor: 'pointer' }}
|
||||
@@ -165,132 +157,79 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
||||
</tr>
|
||||
<tr className="ticket-row">
|
||||
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
|
||||
<EditorDropdown
|
||||
value={ticket.assignedTo}
|
||||
onChange={handleEditorChange}
|
||||
/>
|
||||
<EditorDropdown value={ticket.assignedTo} onChange={handleEditorChange} />
|
||||
</td>
|
||||
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
|
||||
<ResponseDropdown
|
||||
value={ticket.responseLevel}
|
||||
onChange={handleResponseChange}
|
||||
/>
|
||||
<ResponseDropdown value={ticket.responseLevel} onChange={handleResponseChange} />
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<>
|
||||
<tr className="worksheet-expansion">
|
||||
<td colSpan={10} className="worksheet-cell">
|
||||
<div className="card" style={{
|
||||
borderRadius: '0 0 12px 12px',
|
||||
marginTop: 0,
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
borderTop: 'none'
|
||||
}}>
|
||||
<div className="card-body" style={{ borderRadius: '0 0 12px 12px', padding: '20px' }}>
|
||||
{/* Bento Box Layout: 2 Spalten */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '20px',
|
||||
alignItems: 'stretch'
|
||||
}}>
|
||||
{/* Linke Spalte: Ticket-Beschreibung (50%) */}
|
||||
<div style={{
|
||||
<tr className="worksheet-expansion">
|
||||
<td colSpan={10} className="worksheet-cell">
|
||||
<div className="card" style={{
|
||||
borderRadius: '0 0 12px 12px',
|
||||
marginTop: 0,
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
borderTop: 'none'
|
||||
}}>
|
||||
<div className="card-body" style={{ borderRadius: '0 0 12px 12px', padding: '20px' }}>
|
||||
|
||||
{/* Tab-Leiste */}
|
||||
<div className="ticket-tabs">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={`ticket-tab ${activeTab === t.id ? 'ticket-tab-active' : ''}`}
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ticket-tab-content">
|
||||
{activeTab === 'details' && (
|
||||
<div style={{
|
||||
background: 'rgba(45, 55, 72, 0.5)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%'
|
||||
}}>
|
||||
<h5 style={{
|
||||
color: 'var(--dark-text)',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
fontSize: '18px',
|
||||
flex: '0 0 auto'
|
||||
}}>
|
||||
📋 Ticket-Beschreibung
|
||||
<h5 style={{ color: 'var(--dark-text)', fontWeight: 'bold', marginBottom: '16px', fontSize: '18px' }}>
|
||||
Ticket-Beschreibung
|
||||
</h5>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.8',
|
||||
color: 'rgba(226, 232, 240, 0.8)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
margin: 0,
|
||||
flex: '1 1 auto',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
<p style={{ fontSize: '14px', lineHeight: '1.8', color: 'rgba(226, 232, 240, 0.85)', whiteSpace: 'pre-wrap', margin: 0 }}>
|
||||
{ticket.details || 'Keine Details vorhanden.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rechte Spalte: Statistics, Buttons (50%) */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
height: '100%'
|
||||
}}>
|
||||
{/* Button Row: Add Worksheet (100%) + History Icon Button */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'stretch'
|
||||
}}>
|
||||
{/* Add Worksheet Button - 100% width minus icon button */}
|
||||
<button
|
||||
{activeTab === 'worksheets' && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'stretch', marginBottom: '16px' }}>
|
||||
<button
|
||||
className="btn"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '8px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
flex: 1
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.4)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
color: 'white', border: 'none', padding: '12px 20px', borderRadius: '8px',
|
||||
fontWeight: 'bold', cursor: 'pointer', flex: 1
|
||||
}}
|
||||
onClick={() => setShowCreateWorksheet(true)}
|
||||
>
|
||||
<FaPlus style={{ marginRight: '8px' }} /> Add Worksheet
|
||||
</button>
|
||||
|
||||
{/* History Icon Button - klein, grau, nur Icon */}
|
||||
<button
|
||||
style={{
|
||||
background: '#616161',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '44px',
|
||||
width: '44px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#757575'
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#616161'
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
}}
|
||||
type="button"
|
||||
style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '12px', borderRadius: '8px', cursor: 'pointer', minWidth: '44px' }}
|
||||
onClick={() => setShowProjectsModal(true)}
|
||||
title="Projekte bearbeiten"
|
||||
>
|
||||
<FaPen size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{ background: '#616161', color: 'white', border: 'none', padding: '12px', borderRadius: '8px', cursor: 'pointer', minWidth: '44px' }}
|
||||
onClick={() => setShowHistoryModal(true)}
|
||||
title="Status History"
|
||||
>
|
||||
@@ -298,55 +237,67 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Statistiken */}
|
||||
{worksheets.length > 0 && (
|
||||
<div style={{
|
||||
background: 'rgba(45, 55, 72, 0.5)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
flex: '1 1 auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0
|
||||
}}>
|
||||
<div style={{ background: 'rgba(45, 55, 72, 0.5)', borderRadius: '12px', padding: '16px', border: '1px solid rgba(16, 185, 129, 0.2)', marginBottom: '16px' }}>
|
||||
<WorksheetStats worksheets={worksheets} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gesamtarbeitszeit und Worksheet-Liste - 100% Breite unter dem Bento Box */}
|
||||
<div style={{
|
||||
marginTop: '20px',
|
||||
width: '100%'
|
||||
}}>
|
||||
<WorksheetList
|
||||
worksheets={worksheets}
|
||||
totalTime={getTotalTime()}
|
||||
loading={worksheetsLoading}
|
||||
/>
|
||||
</div>
|
||||
<WorksheetList worksheets={worksheets} totalTime={getTotalTime()} loading={worksheetsLoading} onEditTime={setEditTimeWorksheet} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'invoices' && <InvoicePanel ticket={ticket} />}
|
||||
|
||||
{activeTab === 'documents' && <DocumentsPanel ticket={ticket} />}
|
||||
|
||||
{activeTab === 'project' && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-green"
|
||||
style={{ marginBottom: '12px' }}
|
||||
onClick={() => setShowProjectsModal(true)}
|
||||
>
|
||||
<FaPen style={{ marginRight: 6 }} /> Projekte zuweisen / bearbeiten
|
||||
</button>
|
||||
<TicketAssignedProjectsPanel ticket={ticket} refreshKey={projectsRefreshKey} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
<CreateWorksheetModal
|
||||
|
||||
<CreateWorksheetModal
|
||||
isOpen={showCreateWorksheet}
|
||||
onClose={() => setShowCreateWorksheet(false)}
|
||||
workorder={ticket}
|
||||
onCreate={handleCreateWorksheet}
|
||||
/>
|
||||
|
||||
|
||||
<EditWorksheetTimeModal
|
||||
isOpen={Boolean(editTimeWorksheet)}
|
||||
onClose={() => setEditTimeWorksheet(null)}
|
||||
worksheet={editTimeWorksheet}
|
||||
onSave={handleSaveWorksheetTime}
|
||||
/>
|
||||
|
||||
<StatusHistoryModal
|
||||
isOpen={showHistoryModal}
|
||||
onClose={() => setShowHistoryModal(false)}
|
||||
worksheets={worksheets}
|
||||
ticket={ticket}
|
||||
/>
|
||||
|
||||
<TicketProjectsModal
|
||||
isOpen={showProjectsModal}
|
||||
onClose={() => setShowProjectsModal(false)}
|
||||
ticket={ticket}
|
||||
onUpdated={() => setProjectsRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
<tr className="spacer">
|
||||
<td colSpan={10} style={{ height: '12px', background: 'transparent', border: 'none' }}></td>
|
||||
</tr>
|
||||
|
||||
109
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 { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp } from 'react-icons/fa'
|
||||
|
||||
export default function WorksheetList({ worksheets, totalTime, loading }) {
|
||||
function needsTimeEntry(ws) {
|
||||
return ws.serviceType === 'GIT' && (!ws.startTime || !Number(ws.totalTime))
|
||||
}
|
||||
|
||||
export default function WorksheetList({ worksheets, totalTime, loading, onEditTime }) {
|
||||
const [expandedWorksheets, setExpandedWorksheets] = useState({})
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -49,13 +53,14 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
|
||||
<div className="timeline">
|
||||
{worksheets.map((ws, index) => {
|
||||
const isExpanded = expandedWorksheets[ws.wsid] || false
|
||||
const isGit = ws.serviceType === 'GIT'
|
||||
|
||||
return (
|
||||
<div key={ws.$id} className="timeline-item mb-4" style={{
|
||||
animation: `fadeIn 0.5s ease-in-out ${index * 0.1}s backwards`
|
||||
}}>
|
||||
<div className="card border-0 shadow-sm overflow-hidden" style={{
|
||||
borderLeft: ws.isComment ? '4px solid #10b981' : '4px solid #4a5568',
|
||||
borderLeft: isGit ? '4px solid #f97316' : ws.isComment ? '4px solid #10b981' : '4px solid #4a5568',
|
||||
borderRadius: '8px',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
||||
}} onMouseEnter={(e) => {
|
||||
@@ -70,7 +75,9 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
|
||||
className="card-header d-flex justify-content-between align-items-center py-3"
|
||||
onClick={() => toggleWorksheet(ws.wsid)}
|
||||
style={{
|
||||
background: ws.isComment
|
||||
background: isGit
|
||||
? 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)'
|
||||
: ws.isComment
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
||||
color: 'white',
|
||||
@@ -80,12 +87,16 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
|
||||
transition: 'background 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = ws.isComment
|
||||
e.currentTarget.style.background = isGit
|
||||
? 'linear-gradient(135deg, #ea580c 0%, #c2410c 100%)'
|
||||
: ws.isComment
|
||||
? 'linear-gradient(135deg, #059669 0%, #047857 100%)'
|
||||
: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = ws.isComment
|
||||
e.currentTarget.style.background = isGit
|
||||
? 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)'
|
||||
: ws.isComment
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)'
|
||||
}}
|
||||
@@ -98,13 +109,20 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
|
||||
)}
|
||||
<div>
|
||||
<strong className="fs-6">WSID {ws.wsid}</strong>
|
||||
{ws.isComment && (
|
||||
<span className="badge ms-2" style={{
|
||||
{ws.isComment && !isGit && (
|
||||
<span className="badge ms-2" style={{
|
||||
background: 'rgba(255,255,255,0.3)'
|
||||
}}>
|
||||
<FaComment className="me-1" /> Kommentar
|
||||
</span>
|
||||
)}
|
||||
{needsTimeEntry(ws) && (
|
||||
<span className="badge ms-2" style={{
|
||||
background: 'rgba(255,255,255,0.3)'
|
||||
}}>
|
||||
<FaClock className="me-1" /> Zeit offen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Collapsed: Mitarbeiter & Zeit im Header */}
|
||||
{!isExpanded && (
|
||||
@@ -126,9 +144,31 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<small style={{ opacity: 0.9 }}>
|
||||
{formatDateTime(ws.startDate, ws.startTime)}
|
||||
</small>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
{needsTimeEntry(ws) && onEditTime && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.25)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(255,255,255,0.4)',
|
||||
borderRadius: '6px',
|
||||
padding: '2px 10px',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEditTime(ws)
|
||||
}}
|
||||
>
|
||||
<FaClock className="me-1" /> Zeit nachtragen
|
||||
</button>
|
||||
)}
|
||||
<small style={{ opacity: 0.9 }}>
|
||||
{formatDateTime(ws.startDate, ws.startTime) || formatDateTime(ws.endDate, ws.endTime)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body - Nur wenn expanded */}
|
||||
|
||||
166
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 { account, databases, DATABASE_ID, COLLECTIONS, ID, Query } from '../lib/appwrite'
|
||||
import {
|
||||
account,
|
||||
databases,
|
||||
DATABASE_ID,
|
||||
COLLECTIONS,
|
||||
ID,
|
||||
Query,
|
||||
hasAppwriteSession,
|
||||
isDemoMode,
|
||||
projectId as PROJECT_ID,
|
||||
} from '../lib/appwrite'
|
||||
|
||||
const AuthContext = createContext()
|
||||
|
||||
// Demo mode when Appwrite is not configured
|
||||
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
|
||||
const DEMO_MODE = isDemoMode
|
||||
|
||||
// #region agent log
|
||||
if (typeof window !== 'undefined') {
|
||||
fetch('http://127.0.0.1:7284/ingest/0747da40-b90b-4354-9b84-c9b550a81ec9', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '252827' },
|
||||
body: JSON.stringify({
|
||||
sessionId: '252827',
|
||||
runId: 'pre-fix',
|
||||
hypothesisId: 'B',
|
||||
location: 'AuthContext.jsx:init',
|
||||
message: 'Auth DEMO_MODE resolved',
|
||||
data: { DEMO_MODE, PROJECT_ID: PROJECT_ID ? PROJECT_ID.slice(0, 8) : '' },
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
}).catch(() => {})
|
||||
}
|
||||
// #endregion
|
||||
|
||||
function clearStaleAppwriteSessions() {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return
|
||||
try {
|
||||
const raw = window.localStorage.getItem('cookieFallback')
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw)
|
||||
if (typeof parsed !== 'object' || parsed === null) return
|
||||
for (const key of Object.keys(parsed)) {
|
||||
if (key.startsWith('a_session_') && key !== `a_session_${PROJECT_ID}`) {
|
||||
delete parsed[key]
|
||||
}
|
||||
}
|
||||
window.localStorage.setItem('cookieFallback', JSON.stringify(parsed))
|
||||
} catch {
|
||||
window.localStorage.removeItem('cookieFallback')
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllLocalAppwriteSessions() {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.removeItem('cookieFallback')
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function createSessionWithCleanup(email, password) {
|
||||
try {
|
||||
await account.deleteSessions()
|
||||
} catch { /* alte Sitzung ggf. nur serverseitig */ }
|
||||
clearAllLocalAppwriteSessions()
|
||||
if (account.createEmailPasswordSession) {
|
||||
await account.createEmailPasswordSession(email, password)
|
||||
} else {
|
||||
await account.createEmailSession(email, password)
|
||||
}
|
||||
}
|
||||
|
||||
// Hilfsfunktion: Fügt User automatisch zur employees Collection hinzu
|
||||
async function ensureEmployeeExists(user) {
|
||||
if (!user || DEMO_MODE) return
|
||||
|
||||
|
||||
try {
|
||||
// Prüfe ob User bereits in employees Collection existiert
|
||||
const response = await databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.EMPLOYEES,
|
||||
[Query.equal('userId', user.$id)]
|
||||
)
|
||||
|
||||
// Wenn User noch nicht existiert, füge ihn hinzu
|
||||
|
||||
if (response.documents.length === 0) {
|
||||
await databases.createDocument(
|
||||
DATABASE_ID,
|
||||
@@ -28,13 +90,12 @@ async function ensureEmployeeExists(user) {
|
||||
userId: user.$id,
|
||||
displayName: user.name || user.email,
|
||||
email: user.email,
|
||||
shortcode: '' // Kürzel wird später vom Admin hinzugefügt
|
||||
shortcode: ''
|
||||
}
|
||||
)
|
||||
console.log('✅ User automatisch zur Mitarbeiter-Liste hinzugefügt')
|
||||
}
|
||||
} catch (error) {
|
||||
// Fehler ignorieren wenn Collection nicht existiert oder Permissions fehlen
|
||||
if (error.code !== 404) {
|
||||
console.warn('Could not add user to employees collection:', error.message)
|
||||
}
|
||||
@@ -51,7 +112,6 @@ export function AuthProvider({ children }) {
|
||||
|
||||
async function checkUser() {
|
||||
if (DEMO_MODE) {
|
||||
// Check localStorage for demo session
|
||||
const demoUser = localStorage.getItem('demo_user')
|
||||
if (demoUser) {
|
||||
setUser(JSON.parse(demoUser))
|
||||
@@ -59,18 +119,24 @@ export function AuthProvider({ children }) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (!hasAppwriteSession()) {
|
||||
setUser(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await account.get()
|
||||
setUser(session)
|
||||
// Automatisch zur employees Collection hinzufügen
|
||||
await ensureEmployeeExists(session)
|
||||
} catch (error) {
|
||||
// Kein Fehler loggen beim initialen Check - das ist normal wenn nicht eingeloggt
|
||||
// Nur loggen wenn es ein unerwarteter Fehler ist (nicht 401)
|
||||
if (error.code !== 401 && error.code !== 404) {
|
||||
console.error('Unexpected error checking user:', error)
|
||||
}
|
||||
if (error.code === 401) {
|
||||
clearAllLocalAppwriteSessions()
|
||||
}
|
||||
setUser(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -79,45 +145,45 @@ export function AuthProvider({ children }) {
|
||||
|
||||
async function login(email, password) {
|
||||
if (DEMO_MODE) {
|
||||
// Demo login - accept any credentials
|
||||
const demoUser = { $id: 'demo', email, name: email.split('@')[0] }
|
||||
localStorage.setItem('demo_user', JSON.stringify(demoUser))
|
||||
setUser(demoUser)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Appwrite 1.5.7 / SDK 13.0 - versuche beide Methoden für Kompatibilität
|
||||
try {
|
||||
await account.createEmailSession(email, password)
|
||||
} catch (e) {
|
||||
// Fallback für ältere API
|
||||
if (account.createEmailPasswordSession) {
|
||||
await account.createEmailPasswordSession(email, password)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// User-Daten laden und automatisch zur employees Collection hinzufügen
|
||||
const normalizedEmail = email.trim()
|
||||
const normalizedPassword = password.trim()
|
||||
clearStaleAppwriteSessions()
|
||||
|
||||
await createSessionWithCleanup(normalizedEmail, normalizedPassword)
|
||||
|
||||
const session = await account.get()
|
||||
setUser(session)
|
||||
await ensureEmployeeExists(session)
|
||||
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
let errorMessage = error.message || 'Login fehlgeschlagen'
|
||||
|
||||
// Bessere Fehlermeldungen
|
||||
if (error.code === 401 || errorMessage.includes('Invalid credentials')) {
|
||||
|
||||
if (error.code === 429 || errorMessage.includes('Rate limit')) {
|
||||
errorMessage = 'Zu viele Login-Versuche. Bitte 15–30 Minuten warten und es erneut versuchen.'
|
||||
} else if (errorMessage.includes('missing scopes') && errorMessage.includes('account')) {
|
||||
errorMessage = 'Session konnte nicht gespeichert werden. Bitte Seite neu laden und erneut versuchen.'
|
||||
} else if (
|
||||
error?.type === 'user_session_already_exists' ||
|
||||
errorMessage.includes('session is active')
|
||||
) {
|
||||
errorMessage = 'Es gibt noch eine alte Sitzung. Bitte Seite neu laden und erneut anmelden.'
|
||||
} else if (error.code === 401 || errorMessage.includes('Invalid credentials')) {
|
||||
errorMessage = 'Ungültige Email oder Passwort'
|
||||
} else if (errorMessage.includes('User not found')) {
|
||||
errorMessage = 'Benutzer nicht gefunden. Bitte registriere dich zuerst.'
|
||||
} else if (errorMessage.includes('Email/Password')) {
|
||||
errorMessage = 'Email/Password Authentifizierung ist nicht aktiviert. Bitte aktiviere sie in deinem Appwrite Dashboard unter Auth → Providers.'
|
||||
}
|
||||
|
||||
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
@@ -128,28 +194,25 @@ export function AuthProvider({ children }) {
|
||||
setUser(null)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
await account.deleteSession('current')
|
||||
setUser(null)
|
||||
await account.deleteSessions()
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
} finally {
|
||||
clearAllLocalAppwriteSessions()
|
||||
setUser(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Hilfsfunktion um zu prüfen ob Benutzer Admin ist
|
||||
const isAdmin = () => {
|
||||
if (!user) return false
|
||||
// Prüfe ob Benutzer das "admin" Label hat
|
||||
return user.labels?.includes('admin') || false
|
||||
}
|
||||
const isAdmin = Boolean(user?.labels?.includes('admin'))
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
isAdmin: isAdmin()
|
||||
isAdmin
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, ID } from '../lib/appwrite'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, ID, isDemoMode } from '../lib/appwrite'
|
||||
|
||||
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
|
||||
const DEMO_MODE = isDemoMode
|
||||
|
||||
// Default-Werte für Demo-Modus
|
||||
const DEFAULT_CONFIG = {
|
||||
ticketTypes: [
|
||||
'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request',
|
||||
'Maintenance', 'Project', 'Controlling', 'Development', 'Documentation',
|
||||
'Maintenance', 'Project', 'Webpage', 'Controlling', 'Development', 'Documentation',
|
||||
'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
|
||||
'Rollout', 'Emergency Call', 'Other Services'
|
||||
],
|
||||
@@ -31,6 +31,23 @@ const DEFAULT_CONFIG = {
|
||||
]
|
||||
}
|
||||
|
||||
function normalizePriorities(values) {
|
||||
if (!Array.isArray(values)) return DEFAULT_CONFIG.priorities
|
||||
const parsed = values.map((item, index) => {
|
||||
if (typeof item === 'object' && item !== null) return item
|
||||
if (typeof item === 'string') {
|
||||
try {
|
||||
const value = JSON.parse(item)
|
||||
if (value && typeof value === 'object') return value
|
||||
} catch {
|
||||
return { value: index, label: item }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}).filter(Boolean)
|
||||
return parsed.length > 0 ? parsed : DEFAULT_CONFIG.priorities
|
||||
}
|
||||
|
||||
export function useAdminConfig() {
|
||||
const [config, setConfig] = useState(DEFAULT_CONFIG)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -56,7 +73,7 @@ export function useAdminConfig() {
|
||||
systems: doc.systems || DEFAULT_CONFIG.systems,
|
||||
responseLevels: doc.responseLevels || DEFAULT_CONFIG.responseLevels,
|
||||
serviceTypes: doc.serviceTypes || DEFAULT_CONFIG.serviceTypes,
|
||||
priorities: doc.priorities || DEFAULT_CONFIG.priorities
|
||||
priorities: normalizePriorities(doc.priorities)
|
||||
})
|
||||
} catch (e) {
|
||||
// Config existiert noch nicht (404) - das ist normal, verwende Defaults
|
||||
@@ -99,7 +116,9 @@ export function useAdminConfig() {
|
||||
systems: newConfig.systems,
|
||||
responseLevels: newConfig.responseLevels,
|
||||
serviceTypes: newConfig.serviceTypes,
|
||||
priorities: newConfig.priorities
|
||||
priorities: normalizePriorities(newConfig.priorities).map((priority) =>
|
||||
JSON.stringify(priority)
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -149,4 +168,3 @@ export function useAdminConfig() {
|
||||
refresh: fetchConfig
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, ID, Query } from '../lib/appwrite'
|
||||
import {
|
||||
COLLECTIONS,
|
||||
DATABASE_ID,
|
||||
Query,
|
||||
databases,
|
||||
isDemoMode,
|
||||
} from '../lib/appwrite'
|
||||
import {
|
||||
createCustomerWithPortalAccess,
|
||||
deleteCustomerWithPortalAccess,
|
||||
listCustomersForAdmin,
|
||||
updateCustomerWithPortalAccess,
|
||||
} from '../lib/customerAdminApi'
|
||||
|
||||
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
|
||||
const DEMO_MODE = isDemoMode
|
||||
|
||||
// Demo-Kunden für Testing
|
||||
const DEMO_CUSTOMERS = [
|
||||
@@ -22,12 +34,17 @@ export function useCustomers() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CUSTOMERS,
|
||||
[Query.orderAsc('name')]
|
||||
)
|
||||
setCustomers(response.documents)
|
||||
try {
|
||||
const response = await listCustomersForAdmin()
|
||||
setCustomers(response.customers || [])
|
||||
} catch {
|
||||
const response = await databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CUSTOMERS,
|
||||
[Query.orderAsc('name')]
|
||||
)
|
||||
setCustomers(response.documents || [])
|
||||
}
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Error fetching customers:', err)
|
||||
@@ -50,20 +67,25 @@ export function useCustomers() {
|
||||
|
||||
const createCustomer = async (data) => {
|
||||
if (DEMO_MODE) {
|
||||
const newCustomer = { ...data, $id: Date.now().toString() }
|
||||
const { password, ...rest } = data
|
||||
const newCustomer = {
|
||||
...rest,
|
||||
portalPassword: password || rest.portalPassword || '',
|
||||
$id: Date.now().toString(),
|
||||
}
|
||||
setCustomers(prev => [...prev, newCustomer])
|
||||
return { success: true, data: newCustomer }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await databases.createDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CUSTOMERS,
|
||||
ID.unique(),
|
||||
data
|
||||
)
|
||||
setCustomers(prev => [...prev, response])
|
||||
return { success: true, data: response }
|
||||
const { password, ...fields } = data
|
||||
const result = await createCustomerWithPortalAccess({ ...fields, password })
|
||||
const customer = {
|
||||
...result.customer,
|
||||
portalPassword: result.customer?.portalPassword || password || '',
|
||||
}
|
||||
setCustomers(prev => [...prev, customer])
|
||||
return { success: true, data: customer }
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
@@ -71,19 +93,41 @@ export function useCustomers() {
|
||||
|
||||
const updateCustomer = async (id, data) => {
|
||||
if (DEMO_MODE) {
|
||||
setCustomers(prev => prev.map(c => c.$id === id ? { ...c, ...data } : c))
|
||||
const { password, ...rest } = data
|
||||
setCustomers(prev =>
|
||||
prev.map(c => {
|
||||
if (c.$id !== id) return c
|
||||
const next = { ...c, ...rest }
|
||||
if (password) next.portalPassword = password
|
||||
return next
|
||||
})
|
||||
)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await databases.updateDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CUSTOMERS,
|
||||
id,
|
||||
data
|
||||
const { password, ...fields } = data
|
||||
const payload = { ...fields }
|
||||
if (password) payload.password = password
|
||||
|
||||
const result = await updateCustomerWithPortalAccess(id, payload)
|
||||
const customer = {
|
||||
...result.customer,
|
||||
portalPassword:
|
||||
result.customer?.portalPassword ||
|
||||
password ||
|
||||
undefined,
|
||||
}
|
||||
setCustomers(prev =>
|
||||
prev.map(c => {
|
||||
if (c.$id !== id) return c
|
||||
return {
|
||||
...customer,
|
||||
portalPassword: customer.portalPassword ?? c.portalPassword,
|
||||
}
|
||||
})
|
||||
)
|
||||
setCustomers(prev => prev.map(c => c.$id === id ? response : c))
|
||||
return { success: true, data: response }
|
||||
return { success: true, data: customer }
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
@@ -96,11 +140,7 @@ export function useCustomers() {
|
||||
}
|
||||
|
||||
try {
|
||||
await databases.deleteDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CUSTOMERS,
|
||||
id
|
||||
)
|
||||
await deleteCustomerWithPortalAccess(id)
|
||||
setCustomers(prev => prev.filter(c => c.$id !== id))
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
@@ -118,4 +158,3 @@ export function useCustomers() {
|
||||
deleteCustomer
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +1,65 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { databases, account, DATABASE_ID, COLLECTIONS, ID, Query } from '../lib/appwrite'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
COLLECTIONS,
|
||||
DATABASE_ID,
|
||||
Query,
|
||||
databases,
|
||||
isDemoMode,
|
||||
} from '../lib/appwrite'
|
||||
import {
|
||||
createEmployeeWithLogin,
|
||||
deleteEmployeeWithLogin,
|
||||
listEmployeesForAdmin,
|
||||
updateEmployeeWithLogin,
|
||||
} from '../lib/employeeAdminApi'
|
||||
|
||||
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
|
||||
|
||||
// Demo-Mitarbeiter für Testing
|
||||
const DEMO_MODE = isDemoMode
|
||||
const DEMO_EMPLOYEES = [
|
||||
{ $id: '1', userId: 'user1', displayName: 'Kenso Grimm', email: 'kenso@example.com', shortcode: 'KNSO' },
|
||||
{ $id: '2', userId: 'user2', displayName: 'Christian Lehmann', email: 'christian@example.com', shortcode: 'CHLE' }
|
||||
{
|
||||
$id: '1',
|
||||
userId: 'user1',
|
||||
displayName: 'Kenso Grimm',
|
||||
email: 'kenso@example.com',
|
||||
shortcode: 'KNSO',
|
||||
isAdmin: true,
|
||||
},
|
||||
{
|
||||
$id: '2',
|
||||
userId: 'user2',
|
||||
displayName: 'Christian Lehmann',
|
||||
email: 'christian@example.com',
|
||||
shortcode: 'CHLE',
|
||||
isAdmin: false,
|
||||
},
|
||||
]
|
||||
|
||||
export function useEmployees() {
|
||||
const [employees, setEmployees] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
const fetchEmployees = useCallback(async () => {
|
||||
setLoading(true)
|
||||
|
||||
if (DEMO_MODE) {
|
||||
setEmployees(DEMO_EMPLOYEES)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.EMPLOYEES,
|
||||
[Query.orderAsc('displayName')]
|
||||
)
|
||||
setEmployees(response.documents)
|
||||
if (DEMO_MODE) {
|
||||
setEmployees(DEMO_EMPLOYEES)
|
||||
} else {
|
||||
try {
|
||||
const result = await listEmployeesForAdmin()
|
||||
setEmployees(result.employees || [])
|
||||
} catch {
|
||||
const result = await databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.EMPLOYEES,
|
||||
[Query.orderAsc('displayName')]
|
||||
)
|
||||
setEmployees(result.documents || [])
|
||||
}
|
||||
}
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Error fetching employees:', err)
|
||||
// Wenn Collection nicht existiert, setze leeres Array (kein Fehler)
|
||||
if (err.code === 404 || err.message?.includes('not found')) {
|
||||
setEmployees([])
|
||||
setError(null) // Kein Fehler, Collection existiert einfach noch nicht
|
||||
} else {
|
||||
setError(err.message)
|
||||
setEmployees([])
|
||||
}
|
||||
setEmployees([])
|
||||
setError(err.message || 'Mitarbeiter konnten nicht geladen werden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -52,141 +70,48 @@ export function useEmployees() {
|
||||
}, [fetchEmployees])
|
||||
|
||||
const createEmployee = async (data) => {
|
||||
if (DEMO_MODE) {
|
||||
const newEmployee = { ...data, $id: Date.now().toString() }
|
||||
setEmployees(prev => [...prev, newEmployee])
|
||||
return { success: true, data: newEmployee }
|
||||
}
|
||||
|
||||
try {
|
||||
// Validierung
|
||||
if (!data.userId || !data.displayName) {
|
||||
return { success: false, error: 'userId und displayName sind erforderlich' }
|
||||
}
|
||||
|
||||
const response = await databases.createDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.EMPLOYEES,
|
||||
ID.unique(),
|
||||
{
|
||||
userId: data.userId,
|
||||
displayName: data.displayName,
|
||||
email: data.email || '',
|
||||
shortcode: data.shortcode || ''
|
||||
if (DEMO_MODE) {
|
||||
const employee = {
|
||||
...data,
|
||||
userId: crypto.randomUUID(),
|
||||
$id: Date.now().toString(),
|
||||
}
|
||||
)
|
||||
setEmployees(prev => [...prev, response])
|
||||
return { success: true, data: response }
|
||||
setEmployees((current) => [...current, employee])
|
||||
return { success: true, data: employee }
|
||||
}
|
||||
const result = await createEmployeeWithLogin(data)
|
||||
setEmployees((current) => [...current, result.employee])
|
||||
return { success: true, data: result.employee }
|
||||
} catch (err) {
|
||||
console.error('Error creating employee:', err)
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
const updateEmployee = async (id, data) => {
|
||||
if (DEMO_MODE) {
|
||||
setEmployees(prev => prev.map(e => e.$id === id ? { ...e, ...data } : e))
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await databases.updateDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.EMPLOYEES,
|
||||
id,
|
||||
data
|
||||
if (DEMO_MODE) {
|
||||
setEmployees((current) =>
|
||||
current.map((employee) => employee.$id === id ? { ...employee, ...data } : employee)
|
||||
)
|
||||
return { success: true }
|
||||
}
|
||||
const result = await updateEmployeeWithLogin(id, data)
|
||||
setEmployees((current) =>
|
||||
current.map((employee) => employee.$id === id ? result.employee : employee)
|
||||
)
|
||||
setEmployees(prev => prev.map(e => e.$id === id ? response : e))
|
||||
return { success: true, data: response }
|
||||
return { success: true, data: result.employee }
|
||||
} catch (err) {
|
||||
console.error('Error updating employee:', err)
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEmployee = async (id) => {
|
||||
if (DEMO_MODE) {
|
||||
setEmployees(prev => prev.filter(e => e.$id !== id))
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
try {
|
||||
await databases.deleteDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.EMPLOYEES,
|
||||
id
|
||||
)
|
||||
setEmployees(prev => prev.filter(e => e.$id !== id))
|
||||
if (!DEMO_MODE) await deleteEmployeeWithLogin(id)
|
||||
setEmployees((current) => current.filter((employee) => employee.$id !== id))
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
console.error('Error deleting employee:', err)
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronisiert Appwrite Auth Users mit der employees Collection
|
||||
* Erstellt fehlende Einträge für neue Users
|
||||
*/
|
||||
const syncWithAuthUsers = async () => {
|
||||
if (DEMO_MODE) {
|
||||
return { success: true, message: 'Demo-Modus: Keine Synchronisierung nötig' }
|
||||
}
|
||||
|
||||
setSyncing(true)
|
||||
|
||||
try {
|
||||
// 1. Lade alle Appwrite Auth Users
|
||||
// Hinweis: In Appwrite 1.5.7 gibt es möglicherweise keine direkte List-Users API
|
||||
// für normale User. Diese Funktion benötigt Server-Side Code oder Admin-API-Key.
|
||||
// Für jetzt implementieren wir einen Workaround: Wir bieten ein manuelles Add-Interface.
|
||||
|
||||
// Alternative: Wenn der User Appwrite Admin ist, können wir versuchen:
|
||||
// const users = await account.listUsers() // Funktioniert nur mit Admin-Rechten
|
||||
|
||||
// Da das nicht direkt möglich ist, geben wir eine Info zurück
|
||||
return {
|
||||
success: false,
|
||||
error: 'Automatische Synchronisierung erfordert Admin-API-Zugriff. Bitte füge Mitarbeiter manuell hinzu oder verwende die Appwrite Server API.'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error syncing auth users:', err)
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen Employee-Eintrag für den aktuell eingeloggten User
|
||||
* Nützlich für Self-Service
|
||||
*/
|
||||
const createSelfEmployee = async (shortcode = '') => {
|
||||
if (DEMO_MODE) {
|
||||
return { success: true, message: 'Demo-Modus' }
|
||||
}
|
||||
|
||||
try {
|
||||
// Hole aktuellen User
|
||||
const currentUser = await account.get()
|
||||
|
||||
// Prüfe, ob Employee bereits existiert
|
||||
const existing = employees.find(e => e.userId === currentUser.$id)
|
||||
if (existing) {
|
||||
return { success: false, error: 'Mitarbeiter-Eintrag existiert bereits' }
|
||||
}
|
||||
|
||||
// Erstelle Employee-Eintrag
|
||||
const result = await createEmployee({
|
||||
userId: currentUser.$id,
|
||||
displayName: currentUser.name || currentUser.email,
|
||||
email: currentUser.email,
|
||||
shortcode: shortcode
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (err) {
|
||||
console.error('Error creating self employee:', err)
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
@@ -195,13 +120,9 @@ export function useEmployees() {
|
||||
employees,
|
||||
loading,
|
||||
error,
|
||||
syncing,
|
||||
refresh: fetchEmployees,
|
||||
createEmployee,
|
||||
updateEmployee,
|
||||
deleteEmployee,
|
||||
syncWithAuthUsers,
|
||||
createSelfEmployee
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
24
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 { databases, DATABASE_ID, COLLECTIONS, Query, ID } from '../lib/appwrite'
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, Query, ID, isDemoMode } from '../lib/appwrite'
|
||||
|
||||
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
|
||||
const DEMO_MODE = isDemoMode
|
||||
|
||||
function buildFiltersKey(filters = {}) {
|
||||
return JSON.stringify({
|
||||
limit: filters.limit ?? null,
|
||||
customerId: filters.customerId ?? null,
|
||||
assignedTo: filters.assignedTo ?? null,
|
||||
status: filters.status ?? null,
|
||||
type: filters.type ?? null,
|
||||
priority: filters.priority ?? null,
|
||||
woid: filters.woid ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
// Demo data for testing without Appwrite
|
||||
const lastWeek = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
@@ -69,21 +81,34 @@ export function useWorkorders(filters = {}) {
|
||||
const [workorders, setWorkorders] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const filtersKey = useMemo(() => buildFiltersKey(filters), [
|
||||
filters.limit,
|
||||
filters.customerId,
|
||||
filters.assignedTo,
|
||||
filters.status,
|
||||
filters.type,
|
||||
filters.priority,
|
||||
filters.woid,
|
||||
])
|
||||
|
||||
const fetchWorkorders = useCallback(async () => {
|
||||
const activeFilters = JSON.parse(filtersKey)
|
||||
setLoading(true)
|
||||
|
||||
if (DEMO_MODE) {
|
||||
// Filter demo data
|
||||
let filtered = [...DEMO_WORKORDERS]
|
||||
if (filters.status?.length > 0) {
|
||||
filtered = filtered.filter(wo => filters.status.includes(wo.status))
|
||||
if (activeFilters.status?.length > 0) {
|
||||
filtered = filtered.filter(wo => activeFilters.status.includes(wo.status))
|
||||
}
|
||||
if (filters.priority?.length > 0) {
|
||||
filtered = filtered.filter(wo => filters.priority.includes(wo.priority))
|
||||
if (activeFilters.priority?.length > 0) {
|
||||
filtered = filtered.filter(wo => activeFilters.priority.includes(wo.priority))
|
||||
}
|
||||
if (filters.limit) {
|
||||
filtered = filtered.slice(0, filters.limit)
|
||||
if (activeFilters.woid) {
|
||||
filtered = filtered.filter(wo => String(wo.woid) === String(activeFilters.woid))
|
||||
}
|
||||
if (activeFilters.limit) {
|
||||
filtered = filtered.slice(0, activeFilters.limit)
|
||||
}
|
||||
setWorkorders(filtered)
|
||||
setLoading(false)
|
||||
@@ -93,39 +118,36 @@ export function useWorkorders(filters = {}) {
|
||||
try {
|
||||
const queries = [Query.orderDesc('$createdAt')]
|
||||
|
||||
if (filters.limit) {
|
||||
queries.push(Query.limit(filters.limit))
|
||||
if (activeFilters.limit) {
|
||||
queries.push(Query.limit(activeFilters.limit))
|
||||
}
|
||||
|
||||
// Für Arrays: In Appwrite 1.5.7 gibt es kein Query.or()
|
||||
// Wir filtern clientseitig für mehrere Werte
|
||||
if (filters.status && filters.status.length > 0) {
|
||||
if (filters.status.length === 1) {
|
||||
queries.push(Query.equal('status', filters.status[0]))
|
||||
}
|
||||
// Für mehrere Werte: Clientseitig filtern (siehe unten)
|
||||
// Appwrite Query.equal mit Array = OR-Match. Serverseitig filtern, damit
|
||||
// das Limit erst NACH dem Statusfilter greift (sonst wuerden archivierte
|
||||
// Tickets die neuesten Slots belegen und aktive verdraengen).
|
||||
if (activeFilters.status && activeFilters.status.length > 0) {
|
||||
queries.push(Query.equal('status', activeFilters.status))
|
||||
}
|
||||
|
||||
if (activeFilters.type && activeFilters.type.length > 0) {
|
||||
queries.push(Query.equal('type', activeFilters.type))
|
||||
}
|
||||
|
||||
if (activeFilters.priority && activeFilters.priority.length > 0) {
|
||||
queries.push(Query.equal('priority', activeFilters.priority))
|
||||
}
|
||||
|
||||
if (filters.type && filters.type.length > 0) {
|
||||
if (filters.type.length === 1) {
|
||||
queries.push(Query.equal('type', filters.type[0]))
|
||||
}
|
||||
// Für mehrere Werte: Clientseitig filtern
|
||||
if (activeFilters.customerId) {
|
||||
queries.push(Query.equal('customerId', activeFilters.customerId))
|
||||
}
|
||||
|
||||
if (filters.priority && filters.priority.length > 0) {
|
||||
if (filters.priority.length === 1) {
|
||||
queries.push(Query.equal('priority', filters.priority[0]))
|
||||
}
|
||||
// Für mehrere Werte: Clientseitig filtern
|
||||
if (activeFilters.assignedTo) {
|
||||
queries.push(Query.equal('assignedTo', activeFilters.assignedTo))
|
||||
}
|
||||
|
||||
if (filters.customerId) {
|
||||
queries.push(Query.equal('customerId', filters.customerId))
|
||||
}
|
||||
|
||||
if (filters.assignedTo) {
|
||||
queries.push(Query.equal('assignedTo', filters.assignedTo))
|
||||
|
||||
// WOID-Direktsuche (z. B. Deep-Link aus den Leads: /tickets?woid=123)
|
||||
if (activeFilters.woid) {
|
||||
queries.push(Query.equal('woid', [String(activeFilters.woid)]))
|
||||
}
|
||||
|
||||
// Debug: Zeige Collection ID
|
||||
@@ -141,23 +163,8 @@ export function useWorkorders(filters = {}) {
|
||||
COLLECTIONS.WORKORDERS,
|
||||
queries
|
||||
)
|
||||
|
||||
// Clientseitige Filterung für Arrays (da Query.or() nicht verfügbar ist)
|
||||
let filteredDocs = response.documents
|
||||
|
||||
if (filters.status && filters.status.length > 1) {
|
||||
filteredDocs = filteredDocs.filter(doc => filters.status.includes(doc.status))
|
||||
}
|
||||
|
||||
if (filters.type && filters.type.length > 1) {
|
||||
filteredDocs = filteredDocs.filter(doc => filters.type.includes(doc.type))
|
||||
}
|
||||
|
||||
if (filters.priority && filters.priority.length > 1) {
|
||||
filteredDocs = filteredDocs.filter(doc => filters.priority.includes(doc.priority))
|
||||
}
|
||||
|
||||
setWorkorders(filteredDocs)
|
||||
|
||||
setWorkorders(response.documents)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
let errorMessage = err.message || 'Fehler beim Laden der Tickets'
|
||||
@@ -174,12 +181,60 @@ export function useWorkorders(filters = {}) {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [filters])
|
||||
}, [filtersKey])
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorkorders()
|
||||
}, [fetchWorkorders])
|
||||
|
||||
const resolveAutomaticTicketFields = async (requestedAssignee = '') => {
|
||||
const [allTicketsResponse, employeesResponse] = await Promise.all([
|
||||
databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.WORKORDERS,
|
||||
[Query.limit(5000)]
|
||||
),
|
||||
databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.EMPLOYEES,
|
||||
[Query.orderAsc('displayName'), Query.limit(500)]
|
||||
),
|
||||
])
|
||||
|
||||
const allTickets = allTicketsResponse.documents || []
|
||||
const employees = employeesResponse.documents || []
|
||||
const maxWoid = allTickets.reduce((highest, ticket) => {
|
||||
const numericId = Number.parseInt(ticket.woid, 10)
|
||||
return Number.isFinite(numericId) ? Math.max(highest, numericId) : highest
|
||||
}, 9999)
|
||||
|
||||
let assignee = requestedAssignee
|
||||
? employees.find((employee) => employee.userId === requestedAssignee)
|
||||
: null
|
||||
|
||||
if (!assignee && employees.length > 0) {
|
||||
const finishedStatuses = new Set(['Closed', 'Completed', 'Cancelled', 'Done'])
|
||||
const activeCounts = new Map(employees.map((employee) => [employee.userId, 0]))
|
||||
for (const ticket of allTickets) {
|
||||
if (!ticket.assignedTo || finishedStatuses.has(ticket.status)) continue
|
||||
if (activeCounts.has(ticket.assignedTo)) {
|
||||
activeCounts.set(ticket.assignedTo, activeCounts.get(ticket.assignedTo) + 1)
|
||||
}
|
||||
}
|
||||
assignee = [...employees].sort((left, right) => {
|
||||
const loadDifference = activeCounts.get(left.userId) - activeCounts.get(right.userId)
|
||||
if (loadDifference !== 0) return loadDifference
|
||||
return (left.displayName || '').localeCompare(right.displayName || '', 'de')
|
||||
})[0]
|
||||
}
|
||||
|
||||
return {
|
||||
woid: String(maxWoid + 1),
|
||||
assignedTo: assignee?.userId || '',
|
||||
assignedName: assignee?.displayName || '',
|
||||
}
|
||||
}
|
||||
|
||||
const createWorkorder = async (data) => {
|
||||
if (DEMO_MODE) {
|
||||
// Finde höchste WOID und +1
|
||||
@@ -199,38 +254,16 @@ export function useWorkorders(filters = {}) {
|
||||
return { success: false, error: 'Das Feld "Topic" ist erforderlich.' }
|
||||
}
|
||||
|
||||
// Status-Automatik: Wenn Mitarbeiter zugewiesen → Status = "Assigned", sonst "Open"
|
||||
const autoStatus = (data.assignedTo && data.assignedTo !== '') ? 'Assigned' : 'Open'
|
||||
|
||||
// Generiere sequentielle 5-stellige WOID (wie im Original-System)
|
||||
const generateWOID = () => {
|
||||
// Finde die höchste bestehende WOID
|
||||
if (workorders.length === 0) {
|
||||
return '10000'; // Starte bei 10000 wenn keine Tickets existieren
|
||||
}
|
||||
|
||||
const maxWoid = Math.max(
|
||||
...workorders
|
||||
.map(wo => parseInt(wo.woid))
|
||||
.filter(woid => !isNaN(woid) && woid > 0)
|
||||
);
|
||||
|
||||
// Wenn keine gültige WOID gefunden wurde, starte bei 10000
|
||||
if (maxWoid === -Infinity || isNaN(maxWoid)) {
|
||||
return '10000';
|
||||
}
|
||||
|
||||
// Gib die nächste Nummer zurück (sequentiell)
|
||||
return (maxWoid + 1).toString();
|
||||
}
|
||||
const automatic = await resolveAutomaticTicketFields(data.assignedTo)
|
||||
const autoStatus = automatic.assignedTo ? 'Assigned' : 'Open'
|
||||
|
||||
// Bereite Daten für Appwrite vor
|
||||
const workorderData = {
|
||||
// Required fields
|
||||
topic: data.topic.trim(),
|
||||
status: data.status || autoStatus, // Verwende übergebenen Status oder automatischen Status
|
||||
status: autoStatus,
|
||||
priority: typeof data.priority === 'number' ? data.priority : parseInt(data.priority) || 1,
|
||||
woid: generateWOID(), // 5-stellige Zahl
|
||||
woid: automatic.woid,
|
||||
|
||||
// Optional fields - nur senden wenn vorhanden
|
||||
type: data.type || '',
|
||||
@@ -238,7 +271,8 @@ export function useWorkorders(filters = {}) {
|
||||
responseLevel: data.responseLevel || '',
|
||||
serviceType: data.serviceType || 'Remote',
|
||||
customerId: data.customerId || '',
|
||||
assignedTo: data.assignedTo || '', // Zugewiesener Mitarbeiter
|
||||
assignedTo: automatic.assignedTo,
|
||||
assignedName: automatic.assignedName,
|
||||
requestedBy: data.requestedBy || '',
|
||||
requestedFor: data.requestedFor || '',
|
||||
startDate: data.startDate || '',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from '../lib/appwrite'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, Query, ID, isDemoMode } from '../lib/appwrite'
|
||||
|
||||
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
|
||||
const DEMO_MODE = isDemoMode
|
||||
|
||||
// Demo data für Testing - Vollständiges Dummy-Ticket 10001 mit allen Worksheets
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import { Client, Account, Databases, Storage, ID, Query } from 'appwrite'
|
||||
|
||||
// Debug: Zeige geladene Umgebungsvariablen (nur in Development)
|
||||
/** Ticket-System Appwrite-Projekt (Fallback wenn keine .env beim Build) */
|
||||
const DEFAULT_PROJECT_ID = '6a1058610003c5a13a05'
|
||||
|
||||
const endpoint = import.meta.env.VITE_APPWRITE_ENDPOINT || 'https://ticket.webklar.com/v1'
|
||||
export const projectId = (
|
||||
import.meta.env.VITE_APPWRITE_PROJECT_ID || DEFAULT_PROJECT_ID
|
||||
).trim()
|
||||
|
||||
/** Demo-Modus nur wenn wirklich keine Projekt-ID (sollte mit Defaults nie passieren) */
|
||||
export const isDemoMode = !projectId
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔧 Appwrite Konfiguration:')
|
||||
console.log('Endpoint:', import.meta.env.VITE_APPWRITE_ENDPOINT || 'NICHT GESETZT')
|
||||
console.log('Project ID:', import.meta.env.VITE_APPWRITE_PROJECT_ID || 'NICHT GESETZT')
|
||||
console.log('Database ID:', import.meta.env.VITE_APPWRITE_DATABASE_ID || 'NICHT GESETZT')
|
||||
console.log('Endpoint:', endpoint)
|
||||
console.log('Project ID:', projectId || 'NICHT GESETZT')
|
||||
console.log('Demo-Modus:', isDemoMode)
|
||||
console.log('Database ID:', import.meta.env.VITE_APPWRITE_DATABASE_ID || 'woms-database')
|
||||
}
|
||||
|
||||
const endpoint = import.meta.env.VITE_APPWRITE_ENDPOINT || 'https://appwrite.webklar.com/v1'
|
||||
const projectId = import.meta.env.VITE_APPWRITE_PROJECT_ID || ''
|
||||
|
||||
if (!projectId) {
|
||||
console.error('❌ FEHLER: VITE_APPWRITE_PROJECT_ID ist nicht gesetzt!')
|
||||
console.error('Bitte überprüfe deine .env Datei im Root-Verzeichnis.')
|
||||
}
|
||||
|
||||
const client = new Client()
|
||||
.setEndpoint(endpoint)
|
||||
.setProject(projectId)
|
||||
const client = new Client().setEndpoint(endpoint).setProject(projectId)
|
||||
|
||||
export const account = new Account(client)
|
||||
export const databases = new Databases(client)
|
||||
@@ -26,18 +31,47 @@ export const storage = new Storage(client)
|
||||
|
||||
export const DATABASE_ID = import.meta.env.VITE_APPWRITE_DATABASE_ID || 'woms-database'
|
||||
|
||||
// Collection IDs - Verwende die tatsächlichen Collection IDs aus Appwrite!
|
||||
export const COLLECTIONS = {
|
||||
WORKORDERS: '6943bf7d001901baa60c', // Collection ID für workorders
|
||||
CONFIG: 'config', // Collection ID für Admin-Konfiguration (wird erstellt)
|
||||
CUSTOMERS: '694bd1fb002b2e583d13', // Collection ID für customers
|
||||
EMPLOYEES: '695280510031c6c6153b', // Collection ID für employees
|
||||
WORKSHEETS: '6952dbcf0032a92e1168', // Collection ID für worksheets ✅
|
||||
WORKORDERS: 'workorders',
|
||||
CONFIG: 'config',
|
||||
CUSTOMERS: 'customers',
|
||||
EMPLOYEES: 'employees',
|
||||
WORKSHEETS: 'worksheets',
|
||||
USERS: 'users',
|
||||
ATTACHMENTS: 'attachments'
|
||||
ATTACHMENTS: 'attachments',
|
||||
WEBSITE_PROJECTS: 'websiteProjects',
|
||||
LEADS: 'leads',
|
||||
LEAD_SETTINGS: 'leadSettings',
|
||||
LEAD_PIPELINE: 'leadPipeline',
|
||||
ROADMAPS: 'roadmaps',
|
||||
ROADMAP_NODES: 'roadmapNodes',
|
||||
}
|
||||
|
||||
export const BUCKET_ID = 'woms-attachments'
|
||||
export const WEBPAGE_TICKET_TYPE = 'Webpage'
|
||||
export const PROJECT_TICKET_TYPE = 'Project'
|
||||
export const PREVIEW_TEMPLATE_NAME = 'webklar-preview-template'
|
||||
|
||||
export const BUCKET_ID = import.meta.env.VITE_APPWRITE_BUCKET_ID || 'woms-attachments'
|
||||
|
||||
/** Prüft ob vermutlich eine Session existiert (cookieFallback oder Same-Origin-Cookie). */
|
||||
export function hasAppwriteSession() {
|
||||
if (typeof window === 'undefined' || !projectId) return false
|
||||
try {
|
||||
const apiHost = new URL(endpoint).hostname
|
||||
if (apiHost === window.location.hostname) {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem('cookieFallback')
|
||||
if (!raw) return false
|
||||
const parsed = JSON.parse(raw)
|
||||
return Boolean(parsed?.[`a_session_${projectId}`])
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
export { ID, Query }
|
||||
|
||||
40
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 { FaServer, FaDesktop, FaPrint, FaNetworkWired } from 'react-icons/fa6'
|
||||
import { PageHeader, Card } from '../components/ui'
|
||||
|
||||
export default function AssetsPage() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
// Placeholder data - would come from Appwrite in production
|
||||
const assetCategories = [
|
||||
{ icon: FaServer, name: 'Servers', count: 0 },
|
||||
{ icon: FaDesktop, name: 'Workstations', count: 0 },
|
||||
{ icon: FaPrint, name: 'Printers', count: 0 },
|
||||
{ icon: FaNetworkWired, name: 'Network Devices', count: 0 }
|
||||
{ icon: FaServer, name: 'Server', count: 0 },
|
||||
{ icon: FaDesktop, name: 'Arbeitsplaetze', count: 0 },
|
||||
{ icon: FaPrint, name: 'Drucker', count: 0 },
|
||||
{ icon: FaNetworkWired, name: 'Netzwerkgeraete', count: 0 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<header className="text-center mb-2">
|
||||
<h2>Assets Management</h2>
|
||||
</header>
|
||||
<div className="page">
|
||||
<PageHeader title="Assets" subtitle="IT-Inventar und Geraete" />
|
||||
|
||||
<div className="search-bar mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search assets..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="input"
|
||||
style={{ width: '100%', maxWidth: '400px' }}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="Assets suchen..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{ maxWidth: 400, marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<div className="assets-grid">
|
||||
<div className="kpi-grid">
|
||||
{assetCategories.map(({ icon: Icon, name, count }) => (
|
||||
<div key={name} className="asset-card">
|
||||
<Icon size={48} className="text-teal" />
|
||||
<h4>{name}</h4>
|
||||
<span className="asset-count">{count}</span>
|
||||
<div key={name} className="kpi">
|
||||
<span className="kpi-accent" />
|
||||
<div className="kpi-label"><Icon /> {name}</div>
|
||||
<div className="kpi-value">{count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-4 text-grey">
|
||||
<p>Asset management module - Connect to Appwrite to manage your IT assets.</p>
|
||||
</div>
|
||||
<Card><p className="muted" style={{ margin: 0 }}>Asset-Modul - mit Appwrite verbinden, um IT-Assets zu verwalten.</p></Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
343
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 { Link } from 'react-router-dom'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||
import { FaTicket, FaClipboardList, FaClock, FaCircleCheck } from 'react-icons/fa6'
|
||||
import { FaTicket, FaClipboardList, FaClock, FaCircleCheck, FaPlus, FaFileInvoiceDollar, FaUpload } from 'react-icons/fa6'
|
||||
import { integrationsApi, formatMoney } from '../lib/integrationsApi'
|
||||
import { useFinanceSummary } from '../hooks/useFinanceSummary'
|
||||
import { PageHeader, Card, Button, Kpi, EmptyState } from '../components/ui'
|
||||
import { StatusPill, PriorityPill } from '../components/ui/StatusPill'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState({
|
||||
totalTickets: 0,
|
||||
openTickets: 0,
|
||||
closedTickets: 0,
|
||||
pendingTickets: 0
|
||||
})
|
||||
const { summary, loading: finLoading } = useFinanceSummary()
|
||||
const [stats, setStats] = useState({ total: 0, open: 0, awaiting: 0, closed: 0 })
|
||||
const [recent, setRecent] = useState([])
|
||||
const [docCount, setDocCount] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchStats() {
|
||||
let active = true
|
||||
;(async () => {
|
||||
try {
|
||||
const [total, open, closed, pending] = await Promise.all([
|
||||
const [total, open, closed, awaiting, recentRes] = await Promise.all([
|
||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.limit(1)]),
|
||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Open'), Query.limit(1)]),
|
||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Closed'), Query.limit(1)]),
|
||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Awaiting'), Query.limit(1)])
|
||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Awaiting'), Query.limit(1)]),
|
||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.orderDesc('$createdAt'), Query.limit(6)]),
|
||||
])
|
||||
|
||||
setStats({
|
||||
totalTickets: total.total,
|
||||
openTickets: open.total,
|
||||
closedTickets: closed.total,
|
||||
pendingTickets: pending.total
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error fetching stats:', err)
|
||||
if (!active) return
|
||||
setStats({ total: total.total, open: open.total, awaiting: awaiting.total, closed: closed.total })
|
||||
setRecent(recentRes.documents)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchStats()
|
||||
try {
|
||||
const d = await integrationsApi.listDocuments({ page_size: 1 })
|
||||
if (active) setDocCount(d.count)
|
||||
} catch { /* ignore */ }
|
||||
})()
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
|
||||
const StatCard = ({ icon: Icon, label, value, color }) => (
|
||||
<div className="stat-card" style={{ borderLeft: `4px solid ${color}` }}>
|
||||
<div className="stat-icon" style={{ color }}>
|
||||
<Icon size={32} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{loading ? '...' : value}</span>
|
||||
<span className="stat-label">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<header className="text-center mb-2">
|
||||
<h2>Dashboard</h2>
|
||||
</header>
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
subtitle="Ueberblick ueber Tickets, Finanzen und Dokumente"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" as={Link} to="/finance"><FaFileInvoiceDollar /> Finanzen</Button>
|
||||
<Button variant="primary" as={Link} to="/tickets"><FaPlus /> Neues Ticket</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="stats-grid">
|
||||
<StatCard icon={FaTicket} label="Total Tickets" value={stats.totalTickets} color="#2196F3" />
|
||||
<StatCard icon={FaClipboardList} label="Open Tickets" value={stats.openTickets} color="#4CAF50" />
|
||||
<StatCard icon={FaClock} label="Pending" value={stats.pendingTickets} color="#FF9800" />
|
||||
<StatCard icon={FaCircleCheck} label="Closed" value={stats.closedTickets} color="#9E9E9E" />
|
||||
<div className="kpi-grid">
|
||||
<Kpi label={<><FaTicket /> Tickets gesamt</>} value={loading ? '...' : stats.total} accent="var(--info)" />
|
||||
<Kpi label={<><FaClipboardList /> Offen</>} value={loading ? '...' : stats.open} accent="var(--ok)" />
|
||||
<Kpi label={<><FaClock /> Wartend</>} value={loading ? '...' : stats.awaiting} accent="var(--warn)" />
|
||||
<Kpi label={<><FaCircleCheck /> Geschlossen</>} value={loading ? '...' : stats.closed} accent="var(--text-faint)" />
|
||||
</div>
|
||||
|
||||
<div className="dashboard-section mt-4">
|
||||
<h3>Quick Actions</h3>
|
||||
<div className="quick-actions">
|
||||
<a href="/tickets" className="btn btn-teal">View All Tickets</a>
|
||||
<a href="/reports" className="btn btn-dark">Generate Report</a>
|
||||
</div>
|
||||
<div className="kpi-grid">
|
||||
<Kpi label="Offener Betrag" value={finLoading ? '...' : formatMoney(summary?.outstanding)} sub={`${summary?.openCount ?? 0} offene Rechnungen`} accent="var(--warn)" />
|
||||
<Kpi label="Ueberfaellig" value={finLoading ? '...' : formatMoney(summary?.overdue)} sub={`${summary?.overdueCount ?? 0} ueberfaellig`} accent="var(--danger)" />
|
||||
<Kpi label="Bezahlt (Monat)" value={finLoading ? '...' : formatMoney(summary?.paidThisMonth)} accent="var(--ok)" />
|
||||
<Kpi label="Dokumente" value={docCount ?? '...'} accent="var(--info)" />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 16 }}>
|
||||
<Card title="Letzte Tickets" pad={false}>
|
||||
{loading ? (
|
||||
<div className="empty">...</div>
|
||||
) : recent.length === 0 ? (
|
||||
<EmptyState title="Keine Tickets" />
|
||||
) : (
|
||||
<div className="list">
|
||||
{recent.map((t) => (
|
||||
<Link key={t.$id} to="/tickets" className="list-row">
|
||||
<span className="mono" style={{ color: 'var(--accent)', minWidth: 56 }}>{t.woid || t.$id.slice(-5)}</span>
|
||||
<div className="grow">
|
||||
<div className="text-ellipsis" style={{ fontWeight: 600 }}>{t.topic || t.title || '-'}</div>
|
||||
<div className="muted">{t.customerName || '-'}</div>
|
||||
</div>
|
||||
<PriorityPill priority={t.priority} />
|
||||
<StatusPill status={t.status} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Schnellaktionen">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button as={Link} to="/tickets"><FaPlus /> Neues Ticket erstellen</Button>
|
||||
<Button as={Link} to="/finance"><FaFileInvoiceDollar /> Rechnung erstellen</Button>
|
||||
<Button as={Link} to="/documents"><FaUpload /> Dokument hochladen</Button>
|
||||
<Button as={Link} to="/customers" variant="ghost">Kunden verwalten</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,66 +1,27 @@
|
||||
import { FaBook, FaCircleQuestion, FaKeyboard } from 'react-icons/fa6'
|
||||
import { PageHeader, Card } from '../components/ui'
|
||||
|
||||
export default function DocsPage() {
|
||||
const sections = [
|
||||
{
|
||||
icon: FaBook,
|
||||
title: 'Getting Started',
|
||||
content: 'Learn how to create and manage work orders in WOMS 2.0.'
|
||||
},
|
||||
{
|
||||
icon: FaCircleQuestion,
|
||||
title: 'FAQ',
|
||||
content: 'Frequently asked questions about the system.'
|
||||
},
|
||||
{
|
||||
icon: FaKeyboard,
|
||||
title: 'Keyboard Shortcuts',
|
||||
content: 'Speed up your workflow with keyboard shortcuts.'
|
||||
}
|
||||
]
|
||||
|
||||
const shortcuts = [
|
||||
{ key: 'N', action: 'New Ticket' },
|
||||
{ key: 'F', action: 'Focus Search' },
|
||||
{ key: 'R', action: 'Refresh List' },
|
||||
{ key: 'Esc', action: 'Close Modal' }
|
||||
{ icon: FaBook, title: 'Erste Schritte', content: 'Tickets, Rechnungen und Dokumente in der Plattform verwalten.' },
|
||||
{ icon: FaCircleQuestion, title: 'FAQ', content: 'Haeufige Fragen zum System.' },
|
||||
{ icon: FaKeyboard, title: 'Tipps', content: 'Schneller arbeiten mit der Plattform.' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<header className="text-center mb-2">
|
||||
<h2>Documentation</h2>
|
||||
</header>
|
||||
|
||||
<div className="docs-grid">
|
||||
<div className="page">
|
||||
<PageHeader title="Dokumentation" subtitle="Hilfe und Anleitungen" />
|
||||
<div className="kpi-grid">
|
||||
{sections.map(({ icon: Icon, title, content }) => (
|
||||
<div key={title} className="doc-card">
|
||||
<Icon size={32} className="text-teal" />
|
||||
<h4>{title}</h4>
|
||||
<p className="text-grey">{content}</p>
|
||||
</div>
|
||||
<Card key={title}>
|
||||
<div className="flex items-center gap-3" style={{ marginBottom: 8 }}>
|
||||
<Icon size={22} style={{ color: 'var(--accent)' }} />
|
||||
<strong>{title}</strong>
|
||||
</div>
|
||||
<p className="muted" style={{ margin: 0 }}>{content}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="shortcuts-section mt-4">
|
||||
<h3 className="text-center">Keyboard Shortcuts</h3>
|
||||
<table className="table" style={{ maxWidth: '400px', margin: '0 auto' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shortcuts.map(({ key, action }) => (
|
||||
<tr key={key}>
|
||||
<td><kbd>{key}</kbd></td>
|
||||
<td>{action}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
111
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 { format, addDays, startOfWeek } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6'
|
||||
import { PageHeader, Card, Button } from '../components/ui'
|
||||
|
||||
export default function PlanboardPage() {
|
||||
const [currentWeek, setCurrentWeek] = useState(startOfWeek(new Date(), { weekStartsOn: 1 }))
|
||||
|
||||
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeek, i))
|
||||
|
||||
const navigateWeek = (direction) => {
|
||||
setCurrentWeek(prev => addDays(prev, direction * 7))
|
||||
}
|
||||
const navigateWeek = (dir) => setCurrentWeek((prev) => addDays(prev, dir * 7))
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<header className="text-center mb-2">
|
||||
<h2>Planboard</h2>
|
||||
</header>
|
||||
|
||||
<div className="planboard-nav text-center mb-2">
|
||||
<button className="btn btn-dark" onClick={() => navigateWeek(-1)}>← Previous Week</button>
|
||||
<span className="mx-2">
|
||||
{format(currentWeek, 'dd.MM.yyyy')} - {format(addDays(currentWeek, 6), 'dd.MM.yyyy')}
|
||||
</span>
|
||||
<button className="btn btn-dark" onClick={() => navigateWeek(1)}>Next Week →</button>
|
||||
</div>
|
||||
|
||||
<div className="planboard-grid">
|
||||
{weekDays.map(day => (
|
||||
<div key={day.toISOString()} className="planboard-day">
|
||||
<div className="day-header">
|
||||
<strong>{format(day, 'EEEE')}</strong>
|
||||
<span>{format(day, 'dd.MM')}</span>
|
||||
</div>
|
||||
<div className="day-content">
|
||||
<p className="text-grey text-small">No tasks scheduled</p>
|
||||
</div>
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
title="Planboard"
|
||||
subtitle="Wochenplanung"
|
||||
actions={
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button variant="ghost" onClick={() => navigateWeek(-1)}><FaChevronLeft /></Button>
|
||||
<span className="muted" style={{ minWidth: 200, textAlign: 'center' }}>
|
||||
{format(currentWeek, 'dd.MM.yyyy')} - {format(addDays(currentWeek, 6), 'dd.MM.yyyy')}
|
||||
</span>
|
||||
<Button variant="ghost" onClick={() => navigateWeek(1)}><FaChevronRight /></Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="text-center mt-4 text-grey">
|
||||
<p>Drag and drop tickets to schedule them on the planboard.</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 12 }}>
|
||||
{weekDays.map((day) => (
|
||||
<Card key={day.toISOString()} pad={false}>
|
||||
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column' }}>
|
||||
<strong style={{ fontSize: 13 }}>{format(day, 'EEEE', { locale: de })}</strong>
|
||||
<span className="faint" style={{ fontSize: 12 }}>{format(day, 'dd.MM')}</span>
|
||||
</div>
|
||||
<div style={{ padding: 12, minHeight: 120 }}>
|
||||
<p className="faint" style={{ fontSize: 12 }}>Keine Termine</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,39 +1,758 @@
|
||||
import { useState } from 'react'
|
||||
import { FaFolder, FaPlus } from 'react-icons/fa6'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
FaFolder,
|
||||
FaPlus,
|
||||
FaArrowsRotate,
|
||||
FaPen,
|
||||
FaBoxArchive,
|
||||
FaBoxOpen,
|
||||
FaGlobe,
|
||||
FaLock,
|
||||
FaCircleInfo,
|
||||
FaClone,
|
||||
} from 'react-icons/fa6'
|
||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||
import { useCustomers } from '../hooks/useCustomers'
|
||||
import { useWorkorders } from '../hooks/useWorkorders'
|
||||
import { useEmployees } from '../hooks/useEmployees'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import {
|
||||
createProjectFromTemplate,
|
||||
syncGiteaRepos,
|
||||
updateWebsiteProject,
|
||||
archiveWebsiteProject,
|
||||
unarchiveWebsiteProject,
|
||||
} from '../lib/projectAdminApi'
|
||||
import PreviewLinkButton from '../components/PreviewLinkButton'
|
||||
import GiteaLinkButton from '../components/GiteaLinkButton'
|
||||
|
||||
function slugify(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 100)
|
||||
}
|
||||
|
||||
const WORKORDER_FILTERS = { limit: 500 }
|
||||
|
||||
// Projekt-Typen (= "Template"/Hosting-Modus)
|
||||
const TYPES = {
|
||||
preview: {
|
||||
label: 'Preview (mit Login)',
|
||||
short: 'Preview',
|
||||
badge: 'badge-info',
|
||||
icon: <FaLock />,
|
||||
hint: 'Login-geschuetzte Vorschau unter <subdomain>.project.webklar.com',
|
||||
},
|
||||
website: {
|
||||
label: 'Oeffentliche Website',
|
||||
short: 'Website',
|
||||
badge: 'badge-ok',
|
||||
icon: <FaGlobe />,
|
||||
hint: 'Oeffentlich erreichbar ohne Login unter <subdomain>.project.webklar.com',
|
||||
},
|
||||
template: {
|
||||
label: 'Vorlage',
|
||||
short: 'Vorlage',
|
||||
badge: 'badge-warn',
|
||||
icon: <FaClone />,
|
||||
hint: 'Website-Vorlage (login-geschuetzt) - erscheint nur fuer Admins in der Vorlagen-Ansicht',
|
||||
adminOnly: true,
|
||||
},
|
||||
project: {
|
||||
label: 'Internes Projekt (kein Hosting)',
|
||||
short: 'Projekt',
|
||||
badge: 'badge-muted',
|
||||
icon: <FaFolder />,
|
||||
hint: 'Nur Gitea-Repository, keine Website/Subdomain',
|
||||
},
|
||||
}
|
||||
|
||||
function typeOf(project) {
|
||||
if (project.isTemplate) return 'template'
|
||||
if (project.isPublic) return 'website'
|
||||
if (project.subdomain) return 'preview'
|
||||
return 'project'
|
||||
}
|
||||
|
||||
// Typen mit Hosting (Subdomain + Deploy)
|
||||
const HOSTED_TYPES = new Set(['preview', 'website', 'template'])
|
||||
|
||||
// Gleiche Logik wie isPreviewProjectReady im Backend (auth.js):
|
||||
// nur 'ready'/'deployed' gelten als tatsaechlich gehostet.
|
||||
function isProjectReady(project) {
|
||||
const st = String(project.provisioningStatus || project.status || '').toLowerCase()
|
||||
return ['ready', 'deployed'].includes(st)
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [projects] = useState([])
|
||||
const { projects, loading, error, fetchAllProjects, assignProjects, unassignProject } = useWebsiteProjects()
|
||||
const { customers } = useCustomers()
|
||||
const { workorders } = useWorkorders(WORKORDER_FILTERS)
|
||||
const { employees } = useEmployees()
|
||||
const { isAdmin } = useAuth()
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [customerFilter, setCustomerFilter] = useState('')
|
||||
const [employeeFilter, setEmployeeFilter] = useState('')
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({
|
||||
displayName: '',
|
||||
subdomain: '',
|
||||
repoName: '',
|
||||
customerId: '',
|
||||
})
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const [createError, setCreateError] = useState('')
|
||||
const [syncLoading, setSyncLoading] = useState(false)
|
||||
const [notice, setNotice] = useState('')
|
||||
const [assignBusyId, setAssignBusyId] = useState('')
|
||||
const [hostBusyId, setHostBusyId] = useState('')
|
||||
const [refreshBusyId, setRefreshBusyId] = useState('')
|
||||
|
||||
// Bearbeiten-Modal
|
||||
const [editProject, setEditProject] = useState(null)
|
||||
const [editForm, setEditForm] = useState({ projectName: '', subdomain: '', projectType: 'preview' })
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
const [editError, setEditError] = useState('')
|
||||
const [archiveBusy, setArchiveBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllProjects()
|
||||
}, [fetchAllProjects])
|
||||
|
||||
const customerMap = useMemo(() => {
|
||||
const map = {}
|
||||
for (const c of customers) {
|
||||
map[c.$id] = c.name || c.code || c.$id
|
||||
}
|
||||
return map
|
||||
}, [customers])
|
||||
|
||||
const ticketMap = useMemo(() => {
|
||||
const map = {}
|
||||
for (const wo of workorders) {
|
||||
map[wo.$id] = wo.woid || wo.$id
|
||||
}
|
||||
return map
|
||||
}, [workorders])
|
||||
|
||||
// Ticket-Dokument-ID -> Workorder (fuer Mitarbeiter-Zuordnung ueber assignedTo)
|
||||
const workorderById = useMemo(() => {
|
||||
const map = {}
|
||||
for (const wo of workorders) map[wo.$id] = wo
|
||||
return map
|
||||
}, [workorders])
|
||||
|
||||
const sortedCustomers = useMemo(
|
||||
() => [...customers].sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''))),
|
||||
[customers]
|
||||
)
|
||||
const sortedEmployees = useMemo(
|
||||
() => [...employees].sort((a, b) => String(a.displayName || '').localeCompare(String(b.displayName || ''))),
|
||||
[employees]
|
||||
)
|
||||
|
||||
const counts = useMemo(() => {
|
||||
// Vorlagen sind aus den normalen Ansichten ausgeblendet (eigene Ansicht, nur Admin)
|
||||
const active = projects.filter((p) => !p.archived && !p.isTemplate)
|
||||
return {
|
||||
all: active.length,
|
||||
unassigned: active.filter((p) => !p.customerId).length,
|
||||
assigned: active.filter((p) => Boolean(p.customerId)).length,
|
||||
archived: projects.filter((p) => p.archived).length,
|
||||
templates: projects.filter((p) => p.isTemplate && !p.archived).length,
|
||||
}
|
||||
}, [projects])
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
// 1) Kategorie (Dropdown)
|
||||
let base
|
||||
if (filter === 'templates') base = projects.filter((p) => p.isTemplate && !p.archived)
|
||||
else if (filter === 'archived') base = projects.filter((p) => p.archived)
|
||||
else {
|
||||
const active = projects.filter((p) => !p.archived && !p.isTemplate)
|
||||
if (filter === 'unassigned') base = active.filter((p) => !p.customerId)
|
||||
else if (filter === 'assigned') base = active.filter((p) => Boolean(p.customerId))
|
||||
else base = active
|
||||
}
|
||||
|
||||
// 2) Textsuche (Titel/Subdomain/Repo)
|
||||
const q = search.trim().toLowerCase()
|
||||
if (q) {
|
||||
base = base.filter((p) =>
|
||||
[p.projectName, p.subdomain, p.repoFullName, p.giteaRepoName].some((v) =>
|
||||
String(v || '').toLowerCase().includes(q)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 3) Kunde
|
||||
if (customerFilter) base = base.filter((p) => p.customerId === customerFilter)
|
||||
|
||||
// 4) Mitarbeiter (ueber das zugeordnete Ticket -> assignedTo)
|
||||
if (employeeFilter) {
|
||||
base = base.filter((p) => {
|
||||
const wo = p.ticketId ? workorderById[p.ticketId] : null
|
||||
return wo && wo.assignedTo === employeeFilter
|
||||
})
|
||||
}
|
||||
|
||||
return base
|
||||
}, [projects, filter, search, customerFilter, employeeFilter, workorderById])
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault()
|
||||
setCreateLoading(true)
|
||||
setCreateError('')
|
||||
try {
|
||||
const result = await createProjectFromTemplate({
|
||||
repoName: createForm.repoName || slugify(createForm.subdomain) || slugify(createForm.displayName),
|
||||
displayName: createForm.displayName,
|
||||
subdomain: slugify(createForm.subdomain),
|
||||
customerId: createForm.customerId || '',
|
||||
ticketId: '',
|
||||
})
|
||||
setShowCreateModal(false)
|
||||
setCreateForm({ displayName: '', subdomain: '', repoName: '', customerId: '' })
|
||||
setNotice(
|
||||
result.previewUrl
|
||||
? `Projekt angelegt - Website: ${result.previewUrl}`
|
||||
: 'Projekt (ohne Website) angelegt.'
|
||||
)
|
||||
await fetchAllProjects()
|
||||
} catch (err) {
|
||||
setCreateError(err.message || 'Projekt konnte nicht angelegt werden')
|
||||
} finally {
|
||||
setCreateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSyncRepos = async () => {
|
||||
setSyncLoading(true)
|
||||
setNotice('')
|
||||
try {
|
||||
const r = await syncGiteaRepos()
|
||||
setNotice(`Gitea-Sync: ${r.synced ?? '?'} Repos (${r.created ?? 0} neu, ${r.updated ?? 0} aktualisiert)`)
|
||||
await fetchAllProjects()
|
||||
} catch (err) {
|
||||
setNotice('Gitea-Sync fehlgeschlagen: ' + (err.message || 'unbekannt'))
|
||||
} finally {
|
||||
setSyncLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAssignCustomer = async (project, customerId) => {
|
||||
setAssignBusyId(project.$id)
|
||||
setNotice('')
|
||||
const r = customerId
|
||||
? await assignProjects([project.$id], { customerId, ticketId: project.ticketId || '' })
|
||||
: await unassignProject(project.$id)
|
||||
if (!r.success) setNotice('Zuordnung fehlgeschlagen: ' + (r.error || 'unbekannt'))
|
||||
await fetchAllProjects()
|
||||
setAssignBusyId('')
|
||||
}
|
||||
|
||||
const handleHostNow = async (project) => {
|
||||
setHostBusyId(project.$id)
|
||||
setNotice('')
|
||||
try {
|
||||
// Deploy ueber die vorhandene PATCH-Route anstossen (Typ bleibt gleich)
|
||||
const r = await updateWebsiteProject(project.$id, { projectType: typeOf(project) })
|
||||
setNotice(
|
||||
r.deploy?.deployed
|
||||
? `"${project.projectName}" wird jetzt gehostet${r.previewUrl ? ': ' + r.previewUrl : ''}`
|
||||
: `Deploy angestossen, aber nicht bestaetigt: ${r.deploy?.error || 'siehe Server-Logs'}`
|
||||
)
|
||||
await fetchAllProjects()
|
||||
} catch (err) {
|
||||
setNotice('Hosten fehlgeschlagen: ' + (err.message || 'unbekannt'))
|
||||
} finally {
|
||||
setHostBusyId('')
|
||||
}
|
||||
}
|
||||
|
||||
// Bereits gehostetes Projekt erneut deployen (nach Website-Ueberarbeitung neu laden)
|
||||
const handleRefresh = async (project) => {
|
||||
setRefreshBusyId(project.$id)
|
||||
setNotice('')
|
||||
try {
|
||||
const r = await updateWebsiteProject(project.$id, { projectType: typeOf(project) })
|
||||
setNotice(
|
||||
r.deploy?.deployed
|
||||
? `"${project.projectName}" wurde aktualisiert${r.previewUrl ? ': ' + r.previewUrl : ''}`
|
||||
: `Aktualisierung angestossen, aber nicht bestaetigt: ${r.deploy?.error || 'siehe Server-Logs'}`
|
||||
)
|
||||
await fetchAllProjects()
|
||||
} catch (err) {
|
||||
setNotice('Aktualisieren fehlgeschlagen: ' + (err.message || 'unbekannt'))
|
||||
} finally {
|
||||
setRefreshBusyId('')
|
||||
}
|
||||
}
|
||||
|
||||
const openEdit = (project) => {
|
||||
setEditError('')
|
||||
setEditProject(project)
|
||||
setEditForm({
|
||||
projectName: project.projectName || '',
|
||||
subdomain: project.subdomain || '',
|
||||
projectType: typeOf(project),
|
||||
})
|
||||
}
|
||||
|
||||
const handleEditSave = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!editProject) return
|
||||
setEditLoading(true)
|
||||
setEditError('')
|
||||
try {
|
||||
const payload = {
|
||||
projectName: editForm.projectName,
|
||||
projectType: editForm.projectType,
|
||||
}
|
||||
if (editForm.projectType !== 'project') {
|
||||
payload.subdomain = slugify(editForm.subdomain)
|
||||
}
|
||||
const result = await updateWebsiteProject(editProject.$id, payload)
|
||||
setEditProject(null)
|
||||
setNotice(
|
||||
result.previewUrl
|
||||
? `Projekt aktualisiert - ${result.isPublic ? 'oeffentlich' : 'Preview'}: ${result.previewUrl}`
|
||||
: 'Projekt aktualisiert.'
|
||||
)
|
||||
await fetchAllProjects()
|
||||
} catch (err) {
|
||||
setEditError(err.message || 'Projekt konnte nicht aktualisiert werden')
|
||||
} finally {
|
||||
setEditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchiveToggle = async (project) => {
|
||||
const isArchived = Boolean(project.archived)
|
||||
if (!isArchived && !window.confirm(`Repository "${project.projectName}" wirklich archivieren? Die Preview-Website wird entfernt.`)) {
|
||||
return
|
||||
}
|
||||
setArchiveBusy(true)
|
||||
setEditError('')
|
||||
try {
|
||||
if (isArchived) {
|
||||
await unarchiveWebsiteProject(project.$id)
|
||||
setNotice(`"${project.projectName}" reaktiviert.`)
|
||||
} else {
|
||||
await archiveWebsiteProject(project.$id)
|
||||
setNotice(`"${project.projectName}" archiviert.`)
|
||||
}
|
||||
setEditProject(null)
|
||||
await fetchAllProjects()
|
||||
} catch (err) {
|
||||
setEditError(err.message || 'Archivieren fehlgeschlagen')
|
||||
} finally {
|
||||
setArchiveBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const FILTER_OPTIONS = [
|
||||
{ value: 'all', label: `Alle Projekte (${counts.all})` },
|
||||
{ value: 'unassigned', label: `Unzugeordnet (${counts.unassigned})` },
|
||||
{ value: 'assigned', label: `Zugeordnet (${counts.assigned})` },
|
||||
// Vorlagen-Ansicht nur fuer Admins
|
||||
...(isAdmin ? [{ value: 'templates', label: `Vorlagen (${counts.templates})` }] : []),
|
||||
{ value: 'archived', label: `Archiviert (${counts.archived})` },
|
||||
]
|
||||
|
||||
// Typ-Optionen im Bearbeiten-Modal: adminOnly-Typen (Vorlage) nur fuer Admins
|
||||
const typeOptions = Object.entries(TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly)
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<header className="text-center mb-2">
|
||||
<h2>Projects</h2>
|
||||
<h2>Website-Projekte</h2>
|
||||
</header>
|
||||
|
||||
<div className="text-center mb-2">
|
||||
<button className="btn btn-teal">
|
||||
<FaPlus /> New Project
|
||||
<div className="mb-2" style={{ display: 'flex', gap: '10px', justifyContent: 'center', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input
|
||||
type="search"
|
||||
className="form-control"
|
||||
placeholder="Nach Titel suchen…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ flex: '1 1 220px', minWidth: '180px', maxWidth: '320px' }}
|
||||
/>
|
||||
<select
|
||||
className="form-control"
|
||||
style={{ width: 'auto', minWidth: '170px' }}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
>
|
||||
{FILTER_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="form-control"
|
||||
style={{ width: 'auto', minWidth: '150px' }}
|
||||
value={customerFilter}
|
||||
onChange={(e) => setCustomerFilter(e.target.value)}
|
||||
title="Nach Kunde filtern"
|
||||
>
|
||||
<option value="">Alle Kunden</option>
|
||||
{sortedCustomers.map((c) => (
|
||||
<option key={c.$id} value={c.$id}>
|
||||
{c.name}{c.code ? ` (${c.code})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="form-control"
|
||||
style={{ width: 'auto', minWidth: '150px' }}
|
||||
value={employeeFilter}
|
||||
onChange={(e) => setEmployeeFilter(e.target.value)}
|
||||
title="Nach Mitarbeiter filtern"
|
||||
>
|
||||
<option value="">Alle Mitarbeiter</option>
|
||||
{sortedEmployees.map((emp) => (
|
||||
<option key={emp.$id} value={emp.userId}>
|
||||
{emp.displayName || emp.userId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-teal"
|
||||
style={{ padding: '8px 12px' }}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
title="Neues Projekt"
|
||||
aria-label="Neues Projekt"
|
||||
>
|
||||
<FaPlus />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-dark"
|
||||
style={{ padding: '8px 12px' }}
|
||||
onClick={handleSyncRepos}
|
||||
disabled={syncLoading}
|
||||
title="Gitea-Repos synchronisieren"
|
||||
aria-label="Gitea-Repos synchronisieren"
|
||||
>
|
||||
<FaArrowsRotate style={syncLoading ? { animation: 'spin 1s linear infinite' } : undefined} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
{notice && (
|
||||
<div className="text-center mb-2">
|
||||
<span className="text-grey">{notice}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-center text-grey">Projekte werden geladen...</p>
|
||||
) : filteredProjects.length === 0 ? (
|
||||
<div className="text-center p-4">
|
||||
<FaFolder size={64} className="text-grey" />
|
||||
<p className="text-grey mt-2">No projects yet. Create your first project to get started.</p>
|
||||
<p className="text-grey mt-2">
|
||||
{filter === 'archived' ? 'Keine archivierten Projekte.' : 'Keine Projekte gefunden.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="projects-grid">
|
||||
{projects.map(project => (
|
||||
<div key={project.$id} className="project-card">
|
||||
<h4>{project.name}</h4>
|
||||
<p className="text-grey">{project.description}</p>
|
||||
<div className="project-meta">
|
||||
<span>{project.ticketCount || 0} tickets</span>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))',
|
||||
gap: '16px',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{filteredProjects.map((project) => {
|
||||
const type = typeOf(project)
|
||||
const t = TYPES[type]
|
||||
const hosted = HOSTED_TYPES.has(type)
|
||||
const ready = isProjectReady(project)
|
||||
const needsHosting = hosted && !ready && !project.archived
|
||||
return (
|
||||
<div
|
||||
key={project.$id}
|
||||
className="card"
|
||||
style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px', opacity: project.archived ? 0.72 : 1 }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: '15px', wordBreak: 'break-word' }}>
|
||||
{project.projectName || project.giteaRepoName || '(ohne Name)'}
|
||||
</div>
|
||||
{project.repoFullName && (
|
||||
<div className="text-grey" style={{ fontSize: '12px', marginTop: '2px' }}>
|
||||
{project.repoFullName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-end', flexShrink: 0 }}>
|
||||
<span className={`badge ${t.badge}`} style={{ whiteSpace: 'nowrap' }}>
|
||||
{t.icon} {t.short}
|
||||
</span>
|
||||
{hosted && !project.archived && (
|
||||
<span className={`badge ${ready ? 'badge-ok' : 'badge-warn'}`} style={{ whiteSpace: 'nowrap' }}>
|
||||
{ready ? 'gehostet' : 'nicht deployt'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', fontSize: '13px' }}>
|
||||
<Row label="Subdomain">
|
||||
{project.subdomain
|
||||
? <code>{project.subdomain}</code>
|
||||
: <span className="text-grey">ohne Website</span>}
|
||||
</Row>
|
||||
<Row label="Ticket">
|
||||
{project.ticketId ? (ticketMap[project.ticketId] || project.ticketId) : <span className="text-grey">-</span>}
|
||||
</Row>
|
||||
<Row label="Status">
|
||||
<span className="text-grey">{project.archived ? 'archiviert' : (project.status || '-')}</span>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label" style={{ fontSize: '12px' }}>Kunde</label>
|
||||
<select
|
||||
className="form-control"
|
||||
style={{ fontSize: '13px' }}
|
||||
value={project.customerId || ''}
|
||||
disabled={assignBusyId === project.$id || project.archived}
|
||||
onChange={(e) => handleAssignCustomer(project, e.target.value)}
|
||||
>
|
||||
<option value="">Kein Kunde</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.$id} value={c.$id}>
|
||||
({c.code || ''}) {c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginTop: 'auto' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-dark"
|
||||
style={{ padding: '8px 12px' }}
|
||||
onClick={() => openEdit(project)}
|
||||
title="Bearbeiten"
|
||||
aria-label="Bearbeiten"
|
||||
>
|
||||
<FaPen />
|
||||
</button>
|
||||
{needsHosting ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-teal"
|
||||
style={{ padding: '8px 12px' }}
|
||||
onClick={() => handleHostNow(project)}
|
||||
disabled={hostBusyId === project.$id}
|
||||
title="Projekt jetzt deployen/hosten"
|
||||
aria-label="Projekt jetzt deployen/hosten"
|
||||
>
|
||||
<FaGlobe style={hostBusyId === project.$id ? { animation: 'spin 1s linear infinite' } : undefined} />
|
||||
</button>
|
||||
) : (
|
||||
<PreviewLinkButton href={project.previewUrl} />
|
||||
)}
|
||||
{hosted && ready && !project.archived && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-dark"
|
||||
style={{ padding: '8px 12px' }}
|
||||
onClick={() => handleRefresh(project)}
|
||||
disabled={refreshBusyId === project.$id}
|
||||
title="Aktualisieren – Website neu deployen"
|
||||
aria-label="Aktualisieren – Website neu deployen"
|
||||
>
|
||||
<FaArrowsRotate
|
||||
style={refreshBusyId === project.$id ? { animation: 'spin 1s linear infinite' } : undefined}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<GiteaLinkButton href={project.giteaRepoUrl} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- Neues Projekt --- */}
|
||||
{showCreateModal && (
|
||||
<div className="overlay">
|
||||
<span className="overlay-close" onClick={() => setShowCreateModal(false)}>×</span>
|
||||
<div className="overlay-content">
|
||||
<h2 className="mb-2">Neues Website-Projekt</h2>
|
||||
{createError && (
|
||||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Anzeigename</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={createForm.displayName}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, displayName: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Subdomain (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={createForm.subdomain}
|
||||
onChange={(e) =>
|
||||
setCreateForm((p) => ({
|
||||
...p,
|
||||
subdomain: e.target.value,
|
||||
repoName: p.repoName || slugify(e.target.value),
|
||||
}))
|
||||
}
|
||||
placeholder="leer lassen = Projekt ohne Website"
|
||||
/>
|
||||
<small className="text-grey">
|
||||
Mit Subdomain wird die Website automatisch unter https://<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>
|
||||
)
|
||||
}
|
||||
|
||||
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 { format, subDays } from 'date-fns'
|
||||
import { FaFileExport, FaChartBar } from 'react-icons/fa6'
|
||||
import { PageHeader, Card, Button, Field, EmptyState } from '../components/ui'
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: format(subDays(new Date(), 30), 'yyyy-MM-dd'),
|
||||
to: format(new Date(), 'yyyy-MM-dd')
|
||||
to: format(new Date(), 'yyyy-MM-dd'),
|
||||
})
|
||||
const [reportType, setReportType] = useState('tickets')
|
||||
|
||||
const reportTypes = [
|
||||
{ value: 'tickets', label: 'Ticket Summary' },
|
||||
{ value: 'performance', label: 'Performance Report' },
|
||||
{ value: 'customer', label: 'Customer Report' },
|
||||
{ value: 'technician', label: 'Technician Report' }
|
||||
{ value: 'tickets', label: 'Ticket-Uebersicht' },
|
||||
{ value: 'performance', label: 'Leistungsbericht' },
|
||||
{ value: 'customer', label: 'Kundenbericht' },
|
||||
{ value: 'technician', label: 'Techniker-Bericht' },
|
||||
]
|
||||
|
||||
const handleGenerateReport = () => {
|
||||
// Would generate report from Appwrite data
|
||||
alert(`Generating ${reportType} report from ${dateRange.from} to ${dateRange.to}`)
|
||||
const handleGenerate = () => {
|
||||
alert(`Bericht "${reportType}" von ${dateRange.from} bis ${dateRange.to}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<header className="text-center mb-2">
|
||||
<h2>Reports</h2>
|
||||
</header>
|
||||
<div className="page">
|
||||
<PageHeader title="Reports" subtitle="Auswertungen und Exporte" />
|
||||
|
||||
<div className="report-filters card p-2">
|
||||
<div className="filter-row">
|
||||
<label>
|
||||
Report Type:
|
||||
<select
|
||||
value={reportType}
|
||||
onChange={(e) => setReportType(e.target.value)}
|
||||
className="input ml-1"
|
||||
>
|
||||
{reportTypes.map(type => (
|
||||
<option key={type.value} value={type.value}>{type.label}</option>
|
||||
))}
|
||||
<Card title="Bericht erstellen">
|
||||
<div className="flex gap-3 wrap">
|
||||
<Field label="Berichtstyp">
|
||||
<select className="form-control" value={reportType} onChange={(e) => setReportType(e.target.value)}>
|
||||
{reportTypes.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</Field>
|
||||
<Field label="Von"><input className="form-control" type="date" value={dateRange.from} onChange={(e) => setDateRange((p) => ({ ...p, from: e.target.value }))} /></Field>
|
||||
<Field label="Bis"><input className="form-control" type="date" value={dateRange.to} onChange={(e) => setDateRange((p) => ({ ...p, to: e.target.value }))} /></Field>
|
||||
</div>
|
||||
|
||||
<div className="filter-row mt-2">
|
||||
<label>
|
||||
From:
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.from}
|
||||
onChange={(e) => setDateRange(prev => ({ ...prev, from: e.target.value }))}
|
||||
className="input ml-1"
|
||||
/>
|
||||
</label>
|
||||
<label className="ml-2">
|
||||
To:
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.to}
|
||||
onChange={(e) => setDateRange(prev => ({ ...prev, to: e.target.value }))}
|
||||
className="input ml-1"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex gap-2" style={{ marginTop: 8 }}>
|
||||
<Button variant="primary" onClick={handleGenerate}><FaChartBar /> Bericht erstellen</Button>
|
||||
<Button variant="ghost"><FaFileExport /> Als PDF exportieren</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="text-center mt-2">
|
||||
<button className="btn btn-teal" onClick={handleGenerateReport}>
|
||||
<FaChartBar /> Generate Report
|
||||
</button>
|
||||
<button className="btn btn-dark ml-1">
|
||||
<FaFileExport /> Export PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-4 text-grey">
|
||||
<p>Select report parameters and click Generate to view your report.</p>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Card><EmptyState icon={<FaChartBar />} title="Noch kein Bericht" hint="Parameter waehlen und Bericht erstellen." /></Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
205
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 { format } from 'date-fns'
|
||||
import { FaAngleDown, FaSpinner } from 'react-icons/fa6'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { FaAngleDown, FaSpinner, FaPlus, FaList, FaSliders } from 'react-icons/fa6'
|
||||
import { useWorkorders } from '../hooks/useWorkorders'
|
||||
import { useCustomers } from '../hooks/useCustomers'
|
||||
import TicketRow from '../components/TicketRow'
|
||||
import TicketCard from '../components/TicketCard'
|
||||
import CreateTicketModal from '../components/CreateTicketModal'
|
||||
import QuickOverviewModal from '../components/QuickOverviewModal'
|
||||
import { PageHeader, Card, Button, EmptyState } from '../components/ui'
|
||||
|
||||
// Alle Ticket-Status; "Closed" gilt als archiviert und ist standardmaessig ausgeblendet
|
||||
const ALL_STATUSES = [
|
||||
'Open', 'Occupied', 'Assigned', 'Awaiting', 'Added Info',
|
||||
'In Test', 'Halted', 'Aborted', 'Cancelled', 'Closed',
|
||||
]
|
||||
const ARCHIVED_STATUSES = ['Closed']
|
||||
const DEFAULT_STATUSES = ALL_STATUSES.filter((s) => !ARCHIVED_STATUSES.includes(s))
|
||||
|
||||
export default function TicketsPage() {
|
||||
const [limit, setLimit] = useState(10)
|
||||
// Aktive Filter (werden für API-Calls verwendet)
|
||||
const [filters, setFilters] = useState({
|
||||
status: ['Open', 'Occupied', 'Assigned', 'Awaiting', 'Added Info'],
|
||||
type: [],
|
||||
priority: [],
|
||||
limit: 10
|
||||
})
|
||||
// Lokale Filter-Eingaben (werden nur beim Apply angewendet)
|
||||
const [filters, setFilters] = useState({ status: DEFAULT_STATUSES, type: [], priority: [], limit: 10 })
|
||||
const [statusSel, setStatusSel] = useState(DEFAULT_STATUSES)
|
||||
const [localFilters, setLocalFilters] = useState({
|
||||
woid: '',
|
||||
customer: '',
|
||||
userTopic: '',
|
||||
createdDate: '',
|
||||
type: '',
|
||||
system: '',
|
||||
priority: ''
|
||||
woid: '', customer: '', userTopic: '', createdDate: '', type: '', system: '', priority: '',
|
||||
})
|
||||
|
||||
|
||||
const toggleStatus = (status) => {
|
||||
setStatusSel((prev) =>
|
||||
prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
|
||||
)
|
||||
}
|
||||
|
||||
const { workorders, loading, error, refresh, updateWorkorder, createWorkorder } = useWorkorders(filters)
|
||||
const { customers } = useCustomers()
|
||||
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [showOverviewModal, setShowOverviewModal] = useState(false)
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
// Wende lokale Filter auf aktive Filter an
|
||||
setFilters(prev => ({
|
||||
// Deep-Link aus den Leads: /tickets?woid=123 -> Filter vorbelegen + anwenden
|
||||
// (ueber ALLE Status, damit das Ticket unabhaengig vom Status gefunden wird)
|
||||
useEffect(() => {
|
||||
const w = searchParams.get('woid')
|
||||
if (!w) return
|
||||
setLocalFilters((p) => ({ ...p, woid: w }))
|
||||
setShowAdvanced(true)
|
||||
setStatusSel(ALL_STATUSES)
|
||||
setFilters((prev) => ({ ...prev, status: ALL_STATUSES, woid: w }))
|
||||
}, [searchParams])
|
||||
|
||||
const applyFilters = () => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
status: statusSel.length ? statusSel : ALL_STATUSES,
|
||||
woid: localFilters.woid || undefined,
|
||||
customer: localFilters.customer || undefined,
|
||||
userTopic: localFilters.userTopic || undefined,
|
||||
@@ -45,380 +60,112 @@ export default function TicketsPage() {
|
||||
type: localFilters.type ? [localFilters.type] : [],
|
||||
system: localFilters.system ? [localFilters.system] : [],
|
||||
priority: localFilters.priority ? [parseInt(localFilters.priority)] : [],
|
||||
limit: limit
|
||||
limit,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleLimitChange = (e) => {
|
||||
const newLimit = parseInt(e.target.value)
|
||||
setLimit(newLimit)
|
||||
// Limit-Änderung wird sofort angewendet (kein Apply nötig)
|
||||
setFilters(prev => ({ ...prev, limit: newLimit }))
|
||||
}
|
||||
|
||||
const handleUpdate = async (id, data) => {
|
||||
await updateWorkorder(id, data)
|
||||
}
|
||||
|
||||
const handleUpdate = async (id, data) => { await updateWorkorder(id, data) }
|
||||
const handleCreate = async (data) => {
|
||||
const result = await createWorkorder(data)
|
||||
if (result.success) {
|
||||
setShowCreateModal(false)
|
||||
}
|
||||
if (result.success) setShowCreateModal(false)
|
||||
return result
|
||||
}
|
||||
|
||||
const handleLoadMore = () => {
|
||||
setLimit(prev => prev + 10)
|
||||
setFilters(prev => ({ ...prev, limit: prev.limit + 10 }))
|
||||
const loadMore = () => {
|
||||
const next = limit + 10
|
||||
setLimit(next)
|
||||
setFilters((prev) => ({ ...prev, limit: next }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
{/* Sticky Header Container */}
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 100,
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
{/* Compact Control Panel */}
|
||||
<div style={{
|
||||
background: 'rgba(26, 32, 44, 0.4)',
|
||||
backdropFilter: 'blur(25px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(25px) saturate(180%)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(16, 185, 129, 0.3)',
|
||||
overflow: 'hidden',
|
||||
padding: '16px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4)',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
{/* Title Row */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '16px',
|
||||
flexWrap: 'wrap',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<h2 style={{
|
||||
color: 'var(--dark-text)',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
margin: 0
|
||||
}}>
|
||||
Tickets
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
className="btn btn-dark"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
CREATE NEW TICKET
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-dark"
|
||||
onClick={() => setShowOverviewModal(true)}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
QUICK OVERVIEW
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{showAdvancedFilters ? '▲ Hide Filters' : '▼ Show Filters'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
title="Tickets"
|
||||
subtitle="Alle Arbeitsauftraege auf einen Blick"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setShowOverviewModal(true)}><FaList /> Schnelluebersicht</Button>
|
||||
<Button variant="ghost" onClick={() => setShowAdvanced((s) => !s)}><FaSliders /> Filter</Button>
|
||||
<Button variant="primary" onClick={() => setShowCreateModal(true)}><FaPlus /> Neues Ticket</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Main Search Bar */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gap: '12px',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="WOID"
|
||||
className="form-control"
|
||||
style={{ margin: 0 }}
|
||||
value={localFilters.woid || ''}
|
||||
onChange={(e) => setLocalFilters({ ...localFilters, woid: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Customer"
|
||||
className="form-control"
|
||||
style={{ margin: 0 }}
|
||||
value={localFilters.customer || ''}
|
||||
onChange={(e) => setLocalFilters({ ...localFilters, customer: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="User"
|
||||
className="form-control"
|
||||
style={{ margin: 0 }}
|
||||
value={localFilters.userTopic || ''}
|
||||
onChange={(e) => setLocalFilters({ ...localFilters, userTopic: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={handleApplyFilters}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
Apply!
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters - Collapsible */}
|
||||
{showAdvancedFilters && (
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
paddingTop: '16px',
|
||||
borderTop: '1px solid rgba(16, 185, 129, 0.2)'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gap: '12px',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Created Date"
|
||||
className="form-control"
|
||||
style={{ margin: 0 }}
|
||||
value={localFilters.createdDate || ''}
|
||||
onChange={(e) => setLocalFilters({ ...localFilters, createdDate: e.target.value })}
|
||||
/>
|
||||
<select
|
||||
className="form-control"
|
||||
style={{ margin: 0 }}
|
||||
value={localFilters.type || ''}
|
||||
onChange={(e) => setLocalFilters({ ...localFilters, type: e.target.value })}
|
||||
>
|
||||
<option value="">Type / Location</option>
|
||||
<option>Home Office</option>
|
||||
<option>Holidays</option>
|
||||
<option>Trip</option>
|
||||
<option>Supportrequest</option>
|
||||
<option>Change Request</option>
|
||||
<option>Maintenance</option>
|
||||
<option>Project</option>
|
||||
<option>Procurement</option>
|
||||
<option>Emergency Call</option>
|
||||
</select>
|
||||
<select
|
||||
className="form-control"
|
||||
style={{ margin: 0 }}
|
||||
value={localFilters.system || ''}
|
||||
onChange={(e) => setLocalFilters({ ...localFilters, system: e.target.value })}
|
||||
>
|
||||
<option value="">System</option>
|
||||
<option>Client</option>
|
||||
<option>Server</option>
|
||||
<option>Network</option>
|
||||
<option>EDI</option>
|
||||
<option>TOS</option>
|
||||
<option>Reports</option>
|
||||
<option>n/a</option>
|
||||
</select>
|
||||
<select
|
||||
className="form-control"
|
||||
style={{ margin: 0 }}
|
||||
value={localFilters.priority || ''}
|
||||
onChange={(e) => setLocalFilters({ ...localFilters, priority: e.target.value })}
|
||||
>
|
||||
<option value="">Priority</option>
|
||||
<option value="0">None</option>
|
||||
<option value="1">Low</option>
|
||||
<option value="2">Medium</option>
|
||||
<option value="3">High</option>
|
||||
<option value="4">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Quick Selection Buttons */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={() => {
|
||||
setLocalFilters(prev => ({ ...prev, type: 'Procurement' }))
|
||||
setFilters(prev => ({ ...prev, type: ['Procurement'] }))
|
||||
setTimeout(() => refresh(), 0)
|
||||
}}
|
||||
>
|
||||
Procurements
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={() => {
|
||||
setLocalFilters(prev => ({ ...prev, priority: '4' }))
|
||||
setFilters(prev => ({ ...prev, priority: [4] }))
|
||||
setTimeout(() => refresh(), 0)
|
||||
}}
|
||||
>
|
||||
Criticals
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={() => {
|
||||
setLocalFilters(prev => ({ ...prev, priority: '3' }))
|
||||
setFilters(prev => ({ ...prev, priority: [3] }))
|
||||
setTimeout(() => refresh(), 0)
|
||||
}}
|
||||
>
|
||||
Highs
|
||||
</button>
|
||||
<div style={{
|
||||
width: '1px',
|
||||
height: '32px',
|
||||
background: 'rgba(16, 185, 129, 0.3)',
|
||||
margin: '0 8px'
|
||||
}}></div>
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={() => {
|
||||
setLimit(10)
|
||||
setFilters(prev => ({ ...prev, limit: 10 }))
|
||||
setTimeout(() => refresh(), 0)
|
||||
}}
|
||||
>
|
||||
10
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={() => {
|
||||
setLimit(25)
|
||||
setFilters(prev => ({ ...prev, limit: 25 }))
|
||||
setTimeout(() => refresh(), 0)
|
||||
}}
|
||||
>
|
||||
25
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Slider */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span style={{ color: 'var(--dark-text)', minWidth: '120px' }}>
|
||||
Load Limit: {limit}
|
||||
</span>
|
||||
<div style={{ flex: '1' }}>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="50"
|
||||
value={limit}
|
||||
className="slider"
|
||||
onChange={handleLimitChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Card pad className="mb-2" style={{ marginBottom: 16 }}>
|
||||
<div className="toolbar" style={{ marginBottom: showAdvanced ? 16 : 0 }}>
|
||||
<input className="form-control" style={{ maxWidth: 160 }} placeholder="WOID" value={localFilters.woid} onChange={(e) => setLocalFilters({ ...localFilters, woid: e.target.value })} />
|
||||
<input className="form-control" style={{ maxWidth: 220 }} placeholder="Kunde" value={localFilters.customer} onChange={(e) => setLocalFilters({ ...localFilters, customer: e.target.value })} />
|
||||
<input className="form-control search-input" placeholder="Benutzer / Thema" value={localFilters.userTopic} onChange={(e) => setLocalFilters({ ...localFilters, userTopic: e.target.value })} />
|
||||
<Button variant="primary" onClick={applyFilters}>Anwenden</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="table table-hover">
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="text-center p-2">
|
||||
<FaSpinner className="spinner" size={32} />
|
||||
<p>Loading...</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : error ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="text-center p-2 text-red">
|
||||
Error: {error}
|
||||
</td>
|
||||
</tr>
|
||||
) : workorders.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="text-center p-2">
|
||||
No tickets found matching your filters.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
workorders.map(ticket => (
|
||||
<TicketRow
|
||||
key={ticket.$id}
|
||||
ticket={ticket}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{showAdvanced && (
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 16 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12 }}>
|
||||
<input type="date" className="form-control" value={localFilters.createdDate} onChange={(e) => setLocalFilters({ ...localFilters, createdDate: e.target.value })} />
|
||||
<select className="form-control" value={localFilters.type} onChange={(e) => setLocalFilters({ ...localFilters, type: e.target.value })}>
|
||||
<option value="">Typ / Ort</option>
|
||||
<option>Supportrequest</option><option>Change Request</option><option>Maintenance</option>
|
||||
<option>Project</option><option>Procurement</option><option>Emergency Call</option>
|
||||
<option>Home Office</option><option>Holidays</option><option>Trip</option>
|
||||
</select>
|
||||
<select className="form-control" value={localFilters.system} onChange={(e) => setLocalFilters({ ...localFilters, system: e.target.value })}>
|
||||
<option value="">System</option>
|
||||
<option>Client</option><option>Server</option><option>Network</option>
|
||||
<option>EDI</option><option>TOS</option><option>Reports</option><option>n/a</option>
|
||||
</select>
|
||||
<select className="form-control" value={localFilters.priority} onChange={(e) => setLocalFilters({ ...localFilters, priority: e.target.value })}>
|
||||
<option value="">Prioritaet</option>
|
||||
<option value="0">Keine</option><option value="1">Niedrig</option><option value="2">Mittel</option>
|
||||
<option value="3">Hoch</option><option value="4">Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div className="flex gap-2 wrap" style={{ alignItems: 'center', marginBottom: 8 }}>
|
||||
<span className="muted" style={{ fontSize: 13, fontWeight: 600 }}>Status</span>
|
||||
<button type="button" onClick={() => setStatusSel(DEFAULT_STATUSES)} style={{ fontSize: 12, background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0 }}>Standard</button>
|
||||
<button type="button" onClick={() => setStatusSel(ALL_STATUSES)} style={{ fontSize: 12, background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0 }}>Alle inkl. Archiv</button>
|
||||
</div>
|
||||
<div className="flex gap-3 wrap">
|
||||
{ALL_STATUSES.map((status) => (
|
||||
<label key={status} className="flex items-center gap-2" style={{ cursor: 'pointer', fontSize: 14 }}>
|
||||
<input type="checkbox" checked={statusSel.includes(status)} onChange={() => toggleStatus(status)} />
|
||||
{status}
|
||||
{ARCHIVED_STATUSES.includes(status) && <span className="faint" style={{ fontSize: 11 }}>(Archiv)</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 wrap" style={{ marginTop: 12 }}>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setFilters((p) => ({ ...p, priority: [4] })); setTimeout(refresh, 0) }}>Kritische</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setFilters((p) => ({ ...p, priority: [3] })); setTimeout(refresh, 0) }}>Hohe</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setFilters((p) => ({ ...p, type: ['Procurement'] })); setTimeout(refresh, 0) }}>Beschaffung</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{workorders.length > 0 && workorders.length >= limit && (
|
||||
<div style={{ textAlign: 'center', marginTop: '24px' }}>
|
||||
<button
|
||||
className="btn btn-green"
|
||||
style={{
|
||||
padding: '16px 32px',
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
margin: '0 auto'
|
||||
}}
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
Load More <FaAngleDown size={20} />
|
||||
</button>
|
||||
{loading ? (
|
||||
<div className="empty"><FaSpinner className="spinner" /></div>
|
||||
) : error ? (
|
||||
<Card><div className="text-red">Fehler: {error}</div></Card>
|
||||
) : workorders.length === 0 ? (
|
||||
<Card><EmptyState title="Keine Tickets" hint="Keine Tickets passen zu deinen Filtern." action={<Button variant="primary" onClick={() => setShowCreateModal(true)}><FaPlus /> Neues Ticket</Button>} /></Card>
|
||||
) : (
|
||||
<div className="tickets-list">
|
||||
{workorders.map((ticket) => (
|
||||
<TicketCard key={ticket.$id} ticket={ticket} onUpdate={handleUpdate} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
background: 'rgba(45, 55, 72, 0.95)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
padding: '16px',
|
||||
marginTop: '24px',
|
||||
textAlign: 'center',
|
||||
color: '#a0aec0'
|
||||
}}>
|
||||
<p style={{ margin: 0 }}>
|
||||
Summary: Listed a total of <span style={{
|
||||
color: 'var(--green-primary)',
|
||||
fontWeight: 'bold'
|
||||
}}>{workorders.length}</span> Workorders.
|
||||
<br />EOL =)
|
||||
</p>
|
||||
</div>
|
||||
{workorders.length > 0 && workorders.length >= limit && (
|
||||
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||
<Button variant="ghost" onClick={loadMore}>Mehr laden <FaAngleDown /></Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateTicketModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onCreate={handleCreate}
|
||||
customers={customers}
|
||||
/>
|
||||
|
||||
<QuickOverviewModal
|
||||
isOpen={showOverviewModal}
|
||||
onClose={() => setShowOverviewModal(false)}
|
||||
workorders={workorders}
|
||||
/>
|
||||
<CreateTicketModal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} onCreate={handleCreate} customers={customers} />
|
||||
<QuickOverviewModal isOpen={showOverviewModal} onClose={() => setShowOverviewModal(false)} workorders={workorders} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,21 +5,69 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--dark-bg: #1a202c;
|
||||
--dark-card-bg: rgba(45, 55, 72, 0.95);
|
||||
--dark-text: #e2e8f0;
|
||||
--green-primary: #10b981;
|
||||
--green-secondary: #059669;
|
||||
/* Surfaces (calm dark) */
|
||||
--bg: #0e131a;
|
||||
--surface-1: #151b23;
|
||||
--surface-2: #1b2430;
|
||||
--surface-3: #222d3a;
|
||||
--surface-hover: #243040;
|
||||
|
||||
/* Borders / lines */
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--border-strong: rgba(255, 255, 255, 0.14);
|
||||
--border-accent: rgba(16, 185, 129, 0.35);
|
||||
|
||||
/* Text */
|
||||
--text: #e6edf3;
|
||||
--text-muted: #9aa7b4;
|
||||
--text-faint: #6b7785;
|
||||
|
||||
/* Accent */
|
||||
--accent: #10b981;
|
||||
--accent-2: #059669;
|
||||
--accent-soft: rgba(16, 185, 129, 0.14);
|
||||
|
||||
/* Semantic */
|
||||
--info: #3b82f6;
|
||||
--warn: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--ok: #10b981;
|
||||
|
||||
/* Spacing */
|
||||
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px;
|
||||
--sp-4: 16px; --sp-5: 24px; --sp-6: 32px;
|
||||
|
||||
/* Radius */
|
||||
--r-sm: 8px; --r-md: 12px; --r-lg: 16px; --r-pill: 999px;
|
||||
|
||||
/* Elevation */
|
||||
--shadow-1: 0 1px 2px rgba(0,0,0,0.3);
|
||||
--shadow-2: 0 4px 14px rgba(0,0,0,0.3);
|
||||
--shadow-3: 0 12px 32px rgba(0,0,0,0.4);
|
||||
|
||||
/* Legacy aliases (Rueckwaertskompatibilitaet) */
|
||||
--dark-bg: var(--bg);
|
||||
--dark-card-bg: var(--surface-2);
|
||||
--dark-text: var(--text);
|
||||
--green-primary: var(--accent);
|
||||
--green-secondary: var(--accent-2);
|
||||
--gray-700: #374151;
|
||||
--gray-800: #1f2937;
|
||||
--gray-900: #111827;
|
||||
|
||||
--sidebar-w: 248px;
|
||||
--sidebar-w-collapsed: 72px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Lato', sans-serif;
|
||||
background-color: var(--dark-bg);
|
||||
color: var(--dark-text);
|
||||
font-family: 'Lato', system-ui, -apple-system, sans-serif;
|
||||
background: radial-gradient(1200px 700px at 80% -10%, rgba(16,185,129,0.06), transparent 60%),
|
||||
radial-gradient(900px 600px at -10% 110%, rgba(59,130,246,0.05), transparent 55%),
|
||||
var(--bg);
|
||||
background-attachment: fixed;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
@@ -74,8 +122,10 @@ body {
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-top: 64px;
|
||||
padding: 16px;
|
||||
padding: var(--sp-5);
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
@@ -432,19 +482,19 @@ textarea.form-control {
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
/* Status Colors */
|
||||
.status-open { background: #4CAF50 !important; color: #fff !important; }
|
||||
.status-occupied { background: #607D8B !important; color: #fff !important; }
|
||||
.status-assigned { background: #009688 !important; color: #fff !important; }
|
||||
.status-awaiting { background: #ff9800 !important; color: #fff !important; }
|
||||
.status-closed { background: #9e9e9e !important; color: #fff !important; }
|
||||
/* Status Colors (ruhiger, semantisch) */
|
||||
.status-open { background: rgba(16,185,129,0.18) !important; color: #34d399 !important; }
|
||||
.status-occupied { background: rgba(148,163,184,0.18) !important; color: #cbd5e0 !important; }
|
||||
.status-assigned { background: rgba(20,184,166,0.18) !important; color: #2dd4bf !important; }
|
||||
.status-awaiting { background: rgba(245,158,11,0.18) !important; color: #fbbf24 !important; }
|
||||
.status-closed { background: rgba(148,163,184,0.14) !important; color: #94a3b8 !important; }
|
||||
|
||||
/* Priority Colors */
|
||||
.priority-none { background: #2196F3 !important; color: #fff !important; }
|
||||
.priority-low { background: #4CAF50 !important; color: #fff !important; }
|
||||
.priority-medium { background: #ffc107 !important; color: #000 !important; }
|
||||
.priority-high { background: #ff9800 !important; color: #fff !important; }
|
||||
.priority-critical { background: #f44336 !important; color: #fff !important; }
|
||||
/* Priority Colors (ruhiger, semantisch) */
|
||||
.priority-none { background: rgba(59,130,246,0.16) !important; color: #93c5fd !important; }
|
||||
.priority-low { background: rgba(16,185,129,0.16) !important; color: #34d399 !important; }
|
||||
.priority-medium { background: rgba(245,158,11,0.18) !important; color: #fbbf24 !important; }
|
||||
.priority-high { background: rgba(249,115,22,0.18) !important; color: #fb923c !important; }
|
||||
.priority-critical { background: rgba(239,68,68,0.18) !important; color: #f87171 !important; }
|
||||
|
||||
/* Dropdown */
|
||||
.dropdown {
|
||||
@@ -531,6 +581,109 @@ textarea.form-control {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ticket-projects-overlay { padding: 24px; }
|
||||
.ticket-projects-modal { width: 100%; max-width: none; min-height: calc(100vh - 48px); box-sizing: border-box; }
|
||||
.ticket-projects-modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.ticket-projects-modal-header h2 { margin: 0; }
|
||||
.ticket-projects-modal-grid { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(320px, 1fr); gap: 24px; align-items: start; }
|
||||
.ticket-projects-modal-section { background: rgba(45,55,72,0.65); border: 1px solid rgba(59,130,246,0.25); border-radius: 12px; padding: 16px 18px; margin-bottom: 16px; }
|
||||
.ticket-projects-modal-section h6 { font-weight: bold; margin: 0 0 12px; }
|
||||
.ticket-projects-modal-side { display: flex; flex-direction: column; }
|
||||
.ticket-projects-available-list { max-height: min(50vh, 520px); overflow-y: auto; margin-bottom: 8px; display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 6px 16px; }
|
||||
.ticket-projects-available-item { display: flex; gap: 8px; align-items: flex-start; cursor: pointer; padding: 4px 0; }
|
||||
.ticket-projects-create-form { display: grid; grid-template-columns: 1fr 1fr auto; gap: 10px; align-items: end; }
|
||||
@media (max-width: 900px) {
|
||||
.ticket-projects-modal-grid { grid-template-columns: 1fr; }
|
||||
.ticket-projects-create-form { grid-template-columns: 1fr; }
|
||||
.ticket-projects-available-list { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.ticket-projects-overlay {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.ticket-projects-modal {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-height: calc(100vh - 48px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ticket-projects-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.ticket-projects-modal-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ticket-projects-modal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.ticket-projects-modal-section {
|
||||
background: rgba(45, 55, 72, 0.65);
|
||||
border: 1px solid rgba(59, 130, 246, 0.25);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ticket-projects-modal-section h6 {
|
||||
font-weight: bold;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.ticket-projects-modal-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.ticket-projects-available-list {
|
||||
max-height: min(50vh, 520px);
|
||||
overflow-y: auto;
|
||||
margin-bottom: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 6px 16px;
|
||||
}
|
||||
|
||||
.ticket-projects-available-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.ticket-projects-create-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ticket-projects-modal-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ticket-projects-create-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ticket-projects-available-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: rgba(45, 55, 72, 0.98) !important;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3) !important;
|
||||
@@ -686,8 +839,293 @@ textarea.form-control {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
/* Ticket-Detail Tabs */
|
||||
.ticket-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid rgba(16, 185, 129, 0.2);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.ticket-tab {
|
||||
background: rgba(45, 55, 72, 0.5);
|
||||
color: #cbd5e0;
|
||||
border: 1px solid rgba(16, 185, 129, 0.15);
|
||||
border-radius: 8px 8px 0 0;
|
||||
padding: 10px 18px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.ticket-tab:hover {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: var(--dark-text);
|
||||
}
|
||||
.ticket-tab-active {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
.ticket-tab-content {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.col-6 { width: 100%; }
|
||||
.navbar-nav { display: none; }
|
||||
.ticket-tab { padding: 8px 12px; font-size: 13px; }
|
||||
}
|
||||
|
||||
/* ============================================================= */
|
||||
/* =================== REDESIGN (Designsystem) =============== */
|
||||
/* ============================================================= */
|
||||
|
||||
/* --- App-Shell --- */
|
||||
.app-shell { display: flex; height: 100vh; overflow: hidden; }
|
||||
.app-main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.app-scroll { flex: 1; overflow-y: auto; }
|
||||
|
||||
/* --- Sidebar (fix, Labels sichtbar, gruppiert) --- */
|
||||
.side {
|
||||
width: var(--sidebar-w);
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(180deg, var(--surface-1), var(--bg));
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
.side.collapsed { width: var(--sidebar-w-collapsed); }
|
||||
.side-head {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: var(--sp-4); border-bottom: 1px solid var(--border);
|
||||
min-height: 64px;
|
||||
}
|
||||
.side-logo {
|
||||
width: 32px; height: 32px; border-radius: 9px; flex-shrink: 0;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-weight: 800;
|
||||
}
|
||||
.side-brand { font-weight: 800; font-size: 16px; letter-spacing: 0.2px; white-space: nowrap; }
|
||||
.side-brand small { display: block; font-weight: 500; font-size: 11px; color: var(--text-muted); }
|
||||
.side-nav { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-2); }
|
||||
.side-group { margin-bottom: var(--sp-4); }
|
||||
.side-group-label {
|
||||
font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.7px;
|
||||
color: var(--text-faint); padding: 0 var(--sp-3) var(--sp-2);
|
||||
}
|
||||
.side.collapsed .side-group-label,
|
||||
.side.collapsed .side-link-label,
|
||||
.side.collapsed .side-brand { display: none; }
|
||||
.side-link {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: 10px var(--sp-3); margin: 2px var(--sp-1);
|
||||
border-radius: var(--r-sm); color: var(--text-muted);
|
||||
text-decoration: none; transition: all 0.15s ease; white-space: nowrap;
|
||||
}
|
||||
.side-link svg { width: 20px; height: 20px; flex-shrink: 0; }
|
||||
.side-link:hover { background: var(--surface-2); color: var(--text); }
|
||||
.side-link.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
.side-link.active svg { color: var(--accent); }
|
||||
.side-foot { border-top: 1px solid var(--border); padding: var(--sp-3); }
|
||||
.side-toggle {
|
||||
background: transparent; border: none; color: var(--text-muted);
|
||||
cursor: pointer; padding: var(--sp-2); border-radius: var(--r-sm);
|
||||
display: flex; align-items: center; gap: var(--sp-2); width: 100%;
|
||||
}
|
||||
.side-toggle:hover { background: var(--surface-2); color: var(--text); }
|
||||
|
||||
/* --- Page header + container --- */
|
||||
.page { padding: var(--sp-5); max-width: 1320px; margin: 0 auto; width: 100%; }
|
||||
.page-header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: var(--sp-4); margin-bottom: var(--sp-5); flex-wrap: wrap;
|
||||
}
|
||||
.page-title { font-size: 24px; font-weight: 800; margin: 0; }
|
||||
.page-subtitle { color: var(--text-muted); font-size: 14px; margin-top: 4px; }
|
||||
.page-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
|
||||
|
||||
/* --- UI Card / Section --- */
|
||||
.ui-card {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
.ui-card.pad { padding: var(--sp-4); }
|
||||
.ui-card-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: var(--sp-3); padding: var(--sp-4); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.ui-card-head h3 { margin: 0; font-size: 16px; font-weight: 700; }
|
||||
.ui-card-body { padding: var(--sp-4); }
|
||||
|
||||
/* --- Buttons (Redesign-Varianten) --- */
|
||||
.ui-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
padding: 9px 16px; border-radius: var(--r-sm); border: 1px solid transparent;
|
||||
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s ease;
|
||||
background: var(--surface-2); color: var(--text); text-decoration: none;
|
||||
}
|
||||
.ui-btn:hover { background: var(--surface-hover); }
|
||||
.ui-btn-primary { background: var(--accent); color: #06231a; border-color: transparent; }
|
||||
.ui-btn-primary:hover { background: #34d399; }
|
||||
.ui-btn-ghost { background: transparent; border-color: var(--border-strong); color: var(--text-muted); }
|
||||
.ui-btn-ghost:hover { background: var(--surface-2); color: var(--text); }
|
||||
.ui-btn-danger { background: rgba(239,68,68,0.15); color: #f87171; }
|
||||
.ui-btn-danger:hover { background: rgba(239,68,68,0.25); }
|
||||
.ui-btn-sm { padding: 6px 10px; font-size: 13px; }
|
||||
.ui-btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
/* --- Badges / Pills --- */
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 3px 10px; border-radius: var(--r-pill);
|
||||
font-size: 12px; font-weight: 600; line-height: 1.4;
|
||||
background: var(--surface-2); color: var(--text-muted);
|
||||
}
|
||||
.badge .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||
.badge-info { background: rgba(59,130,246,0.16); color: #93c5fd; }
|
||||
.badge-ok { background: rgba(16,185,129,0.16); color: #34d399; }
|
||||
.badge-warn { background: rgba(245,158,11,0.18); color: #fbbf24; }
|
||||
.badge-danger { background: rgba(239,68,68,0.18); color: #f87171; }
|
||||
.badge-muted { background: var(--surface-2); color: var(--text-muted); }
|
||||
|
||||
/* --- KPI / Stat cards --- */
|
||||
.kpi-grid {
|
||||
display: grid; gap: var(--sp-4);
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
.kpi {
|
||||
background: var(--surface-1); border: 1px solid var(--border);
|
||||
border-radius: var(--r-md); padding: var(--sp-4);
|
||||
display: flex; flex-direction: column; gap: 6px; position: relative; overflow: hidden;
|
||||
}
|
||||
.kpi-accent { position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent); }
|
||||
.kpi-label { color: var(--text-muted); font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
||||
.kpi-value { font-size: 28px; font-weight: 800; }
|
||||
.kpi-sub { font-size: 12px; color: var(--text-faint); }
|
||||
/* legacy dashboard stat classes -> new look */
|
||||
.stats-grid { display: grid; gap: var(--sp-4); grid-template-columns: repeat(auto-fit, minmax(200px,1fr)); margin-bottom: var(--sp-5); }
|
||||
.stat-card { background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--sp-4); display: flex; align-items: center; gap: var(--sp-4); }
|
||||
.stat-info { display: flex; flex-direction: column; }
|
||||
.stat-value { font-size: 26px; font-weight: 800; }
|
||||
.stat-label { color: var(--text-muted); font-size: 13px; }
|
||||
.dashboard-section { margin-top: var(--sp-5); }
|
||||
.quick-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; margin-top: var(--sp-3); }
|
||||
|
||||
/* --- Ticket Card (ersetzt Tabelle) --- */
|
||||
.tickets-list { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
.tcard {
|
||||
background: var(--surface-1); border: 1px solid var(--border);
|
||||
border-radius: var(--r-md); overflow: visible; transition: border-color 0.15s ease;
|
||||
}
|
||||
.tcard:hover { border-color: var(--border-strong); }
|
||||
.tcard.open { border-color: var(--border-accent); }
|
||||
.tcard-detail { border-radius: 0 0 var(--r-md) var(--r-md); }
|
||||
.tcard-main {
|
||||
display: grid; grid-template-columns: 92px 1fr auto; gap: var(--sp-4);
|
||||
padding: var(--sp-4); align-items: center; cursor: pointer;
|
||||
}
|
||||
.tcard-woid { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; }
|
||||
.tcard-woid .id { font-weight: 800; font-size: 15px; color: var(--accent); }
|
||||
.tcard-woid .age { font-size: 11px; color: var(--text-faint); }
|
||||
.tcard-body { min-width: 0; }
|
||||
.tcard-row1 { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: 4px; }
|
||||
.tcard-customer { font-weight: 700; }
|
||||
.tcard-loc { color: var(--text-muted); font-size: 13px; }
|
||||
.tcard-topic { color: var(--text-muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tcard-badges { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
|
||||
.tcard-side { display: flex; align-items: center; gap: var(--sp-3); }
|
||||
.tcard-detail { border-top: 1px solid var(--border); padding: var(--sp-4); background: var(--surface-1); }
|
||||
.chevron { color: var(--text-muted); transition: transform 0.2s ease; }
|
||||
.chevron.up { transform: rotate(180deg); }
|
||||
@media (max-width: 720px) {
|
||||
.tcard-main { grid-template-columns: 64px 1fr; }
|
||||
.tcard-side { grid-column: 1 / -1; justify-content: space-between; }
|
||||
}
|
||||
|
||||
/* --- Generic data list rows (Kunden/Finanzen/Dokumente) --- */
|
||||
.list { display: flex; flex-direction: column; }
|
||||
.list-row {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: 12px var(--sp-4); border-bottom: 1px solid var(--border);
|
||||
transition: background 0.12s ease; text-decoration: none; color: var(--text);
|
||||
}
|
||||
.list-row:last-child { border-bottom: none; }
|
||||
.list-row:hover { background: var(--surface-2); }
|
||||
.list-row .grow { flex: 1; min-width: 0; }
|
||||
.list-row .muted { color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
/* --- Clean table (ohne rowSpan-Tricks) --- */
|
||||
.ui-table { width: 100%; border-collapse: collapse; }
|
||||
.ui-table th {
|
||||
text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px;
|
||||
color: var(--text-faint); font-weight: 600; padding: 10px var(--sp-3);
|
||||
border-bottom: 1px solid var(--border); background: transparent;
|
||||
}
|
||||
.ui-table td { padding: 11px var(--sp-3); border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||
.ui-table tbody tr:hover td { background: var(--surface-2); }
|
||||
|
||||
/* --- Segmented control / toolbar --- */
|
||||
.toolbar { display: flex; gap: var(--sp-3); flex-wrap: wrap; align-items: center; margin-bottom: var(--sp-4); }
|
||||
.seg { display: inline-flex; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 3px; gap: 2px; }
|
||||
.seg button {
|
||||
border: none; background: transparent; color: var(--text-muted);
|
||||
padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 600;
|
||||
}
|
||||
.seg button.active { background: var(--accent-soft); color: var(--accent); }
|
||||
.search-input { flex: 1; min-width: 200px; }
|
||||
|
||||
/* --- Empty state --- */
|
||||
.empty { text-align: center; color: var(--text-muted); padding: var(--sp-6) var(--sp-4); }
|
||||
.empty .empty-icon { font-size: 32px; opacity: 0.5; margin-bottom: var(--sp-3); }
|
||||
|
||||
/* --- Field --- */
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--sp-3); }
|
||||
.field label { font-size: 13px; font-weight: 600; color: var(--text-muted); }
|
||||
|
||||
/* --- Tab restyle (ruhiger) --- */
|
||||
.ticket-tabs { border-bottom-color: var(--border); }
|
||||
.ticket-tab { background: var(--surface-2); border-color: var(--border); color: var(--text-muted); }
|
||||
.ticket-tab:hover { background: var(--surface-hover); color: var(--text); }
|
||||
.ticket-tab-active { background: var(--accent); color: #06231a; }
|
||||
|
||||
/* --- Soften legacy cards/forms --- */
|
||||
.card { background: var(--surface-1) !important; border: 1px solid var(--border) !important; border-radius: var(--r-md); }
|
||||
.card-header { background: var(--surface-2); border-bottom: 1px solid var(--border); }
|
||||
.modern-card { background: var(--surface-1); border: 1px solid var(--border); }
|
||||
.modern-card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-2); }
|
||||
.form-control, .form-select { background: var(--surface-2) !important; border: 1px solid var(--border) !important; border-radius: var(--r-sm); }
|
||||
.form-control:focus, .form-select:focus { border-color: var(--accent) !important; box-shadow: 0 0 0 3px var(--accent-soft) !important; }
|
||||
.footer { background: var(--surface-1); border-top: 1px solid var(--border); padding: var(--sp-4); margin-top: 0; }
|
||||
|
||||
/* --- Utilities --- */
|
||||
.muted { color: var(--text-muted); }
|
||||
.faint { color: var(--text-faint); }
|
||||
.flex { display: flex; }
|
||||
.flex-col { display: flex; flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-1 { gap: var(--sp-1); } .gap-2 { gap: var(--sp-2); } .gap-3 { gap: var(--sp-3); } .gap-4 { gap: var(--sp-4); }
|
||||
.grow { flex: 1; min-width: 0; }
|
||||
.wrap { flex-wrap: wrap; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
||||
.text-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.automatic-assignment-note {
|
||||
padding: 12px 14px;
|
||||
border-left: 2px solid var(--accent);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.automatic-assignment-note p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,38 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
// Direkt zu Appwrite (ein Hop weniger als ticket.webklar.com → nginx → Appwrite)
|
||||
const apiTarget =
|
||||
env.VITE_APPWRITE_PROXY_TARGET || 'https://appwrite.webklar.com'
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/v1': {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
timeout: 120_000,
|
||||
proxyTimeout: 120_000,
|
||||
cookieDomainRewrite: 'localhost',
|
||||
configure: (proxy) => {
|
||||
proxy.on('error', (err, req, res) => {
|
||||
console.error('[vite proxy /v1]', err.message)
|
||||
if (res && !res.headersSent) {
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
message: `Proxy-Fehler: ${err.message}. Ist ${apiTarget} erreichbar?`,
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user