Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c884a57d7 | ||
|
|
149b38b94b | ||
|
|
e678899141 | ||
|
|
8cb0bb8146 | ||
|
|
d6becf0151 | ||
|
|
d86f9c07cd | ||
|
|
cf1d653eb2 | ||
|
|
d42ee939e9 | ||
|
|
19464fe20d | ||
|
|
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 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# Ö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
|
||||
@@ -0,0 +1,14 @@
|
||||
# Kopieren nach .env.local (wird nicht von Git versioniert):
|
||||
# cp .env.example .env.local
|
||||
#
|
||||
# Lokal mit Vite-Proxy (empfohlen � 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
|
||||
@@ -0,0 +1,5 @@
|
||||
# Ö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
|
||||
+2
-2
@@ -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/
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"type": "static",
|
||||
"branch": "test",
|
||||
"displayName": "tickte-system",
|
||||
"templateName": "webklar-preview-template",
|
||||
"projectType": "project",
|
||||
"public": false
|
||||
}
|
||||
@@ -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** � 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 � 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 � Verhalten wie nginx auf dem Server.
|
||||
|
||||
## Fehler: „Failed to load resource“ / **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`) — 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ü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) — dann ist der Proxy ok.
|
||||
|
||||
**Notfall ohne Proxy:** In `.env.local` temporär `VITE_APPWRITE_ENDPOINT=https://ticket.webklar.com/v1` — 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�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` � 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`
|
||||
+31
-31
@@ -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"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": ["../../isomorphic-form-data/lib/browser.js", "../../cross-fetch/dist/browser-ponyfill.js", "../../appwrite/node_modules/tslib/tslib.es6.js", "../../appwrite/src/service.ts", "../../appwrite/src/query.ts", "../../appwrite/src/client.ts", "../../appwrite/src/services/account.ts", "../../appwrite/src/services/avatars.ts", "../../appwrite/src/services/databases.ts", "../../appwrite/src/services/functions.ts", "../../appwrite/src/services/graphql.ts", "../../appwrite/src/services/locale.ts", "../../appwrite/src/services/storage.ts", "../../appwrite/src/services/teams.ts", "../../appwrite/src/permission.ts", "../../appwrite/src/role.ts", "../../appwrite/src/id.ts"],
|
||||
"sourcesContent": ["module.exports = window.FormData\r\n", "var global = typeof self !== 'undefined' ? self : this;\r\nvar __self__ = (function () {\r\nfunction F() {\r\nthis.fetch = false;\r\nthis.DOMException = global.DOMException\r\n}\r\nF.prototype = global;\r\nreturn new F();\r\n})();\r\n(function(self) {\r\n\r\nvar irrelevant = (function (exports) {\r\n\r\n var support = {\r\n searchParams: 'URLSearchParams' in self,\r\n iterable: 'Symbol' in self && 'iterator' in Symbol,\r\n blob:\r\n 'FileReader' in self &&\r\n 'Blob' in self &&\r\n (function() {\r\n try {\r\n new Blob();\r\n return true\r\n } catch (e) {\r\n return false\r\n }\r\n })(),\r\n formData: 'FormData' in self,\r\n arrayBuffer: 'ArrayBuffer' in self\r\n };\r\n\r\n function isDataView(obj) {\r\n return obj && DataView.prototype.isPrototypeOf(obj)\r\n }\r\n\r\n if (support.arrayBuffer) {\r\n var viewClasses = [\r\n '[object Int8Array]',\r\n '[object Uint8Array]',\r\n '[object Uint8ClampedArray]',\r\n '[object Int16Array]',\r\n '[object Uint16Array]',\r\n '[object Int32Array]',\r\n '[object Uint32Array]',\r\n '[object Float32Array]',\r\n '[object Float64Array]'\r\n ];\r\n\r\n var isArrayBufferView =\r\n ArrayBuffer.isView ||\r\n function(obj) {\r\n return obj && viewClasses.indexOf(Object.prototype.toString.call(obj)) > -1\r\n };\r\n }\r\n\r\n function normalizeName(name) {\r\n if (typeof name !== 'string') {\r\n name = String(name);\r\n }\r\n if (/[^a-z0-9\\-#$%&'*+.^_`|~]/i.test(name)) {\r\n throw new TypeError('Invalid character in header field name')\r\n }\r\n return name.toLowerCase()\r\n }\r\n\r\n function normalizeValue(value) {\r\n if (typeof value !== 'string') {\r\n value = String(value);\r\n }\r\n return value\r\n }\r\n\r\n // Build a destructive iterator for the value list\r\n function iteratorFor(items) {\r\n var iterator = {\r\n next: function() {\r\n var value = items.shift();\r\n return {done: value === undefined, value: value}\r\n }\r\n };\r\n\r\n if (support.iterable) {\r\n iterator[Symbol.iterator] = function() {\r\n return iterator\r\n };\r\n }\r\n\r\n return iterator\r\n }\r\n\r\n function Headers(headers) {\r\n this.map = {};\r\n\r\n if (headers instanceof Headers) {\r\n headers.forEach(function(value, name) {\r\n this.append(name, value);\r\n }, this);\r\n } else if (Array.isArray(headers)) {\r\n headers.forEach(function(header) {\r\n this.append(header[0], header[1]);\r\n }, this);\r\n } else if (headers) {\r\n Object.getOwnPropertyNames(headers).forEach(function(name) {\r\n this.append(name, headers[name]);\r\n }, this);\r\n }\r\n }\r\n\r\n Headers.prototype.append = function(name, value) {\r\n name = normalizeName(name);\r\n value = normalizeValue(value);\r\n var oldValue = this.map[name];\r\n this.map[name] = oldValue ? oldValue + ', ' + value : value;\r\n };\r\n\r\n Headers.prototype['delete'] = function(name) {\r\n delete this.map[normalizeName(name)];\r\n };\r\n\r\n Headers.prototype.get = function(name) {\r\n name = normalizeName(name);\r\n return this.has(name) ? this.map[name] : null\r\n };\r\n\r\n Headers.prototype.has = function(name) {\r\n return this.map.hasOwnProperty(normalizeName(name))\r\n };\r\n\r\n Headers.prototype.set = function(name, value) {\r\n this.map[normalizeName(name)] = normalizeValue(value);\r\n };\r\n\r\n Headers.prototype.forEach = function(callback, thisArg) {\r\n for (var name in this.map) {\r\n if (this.map.hasOwnProperty(name)) {\r\n callback.call(thisArg, this.map[name], name, this);\r\n }\r\n }\r\n };\r\n\r\n Headers.prototype.keys = function() {\r\n var items = [];\r\n this.forEach(function(value, name) {\r\n items.push(name);\r\n });\r\n return iteratorFor(items)\r\n };\r\n\r\n Headers.prototype.values = function() {\r\n var items = [];\r\n this.forEach(function(value) {\r\n items.push(value);\r\n });\r\n return iteratorFor(items)\r\n };\r\n\r\n Headers.prototype.entries = function() {\r\n var items = [];\r\n this.forEach(function(value, name) {\r\n items.push([name, value]);\r\n });\r\n return iteratorFor(items)\r\n };\r\n\r\n if (support.iterable) {\r\n Headers.prototype[Symbol.iterator] = Headers.prototype.entries;\r\n }\r\n\r\n function consumed(body) {\r\n if (body.bodyUsed) {\r\n return Promise.reject(new TypeError('Already read'))\r\n }\r\n body.bodyUsed = true;\r\n }\r\n\r\n function fileReaderReady(reader) {\r\n return new Promise(function(resolve, reject) {\r\n reader.onload = function() {\r\n resolve(reader.result);\r\n };\r\n reader.onerror = function() {\r\n rejLine truncated
|
||||
"sourcesContent": ["module.exports = window.FormData\n", "var global = typeof self !== 'undefined' ? self : this;\nvar __self__ = (function () {\nfunction F() {\nthis.fetch = false;\nthis.DOMException = global.DOMException\n}\nF.prototype = global;\nreturn new F();\n})();\n(function(self) {\n\nvar irrelevant = (function (exports) {\n\n var support = {\n searchParams: 'URLSearchParams' in self,\n iterable: 'Symbol' in self && 'iterator' in Symbol,\n blob:\n 'FileReader' in self &&\n 'Blob' in self &&\n (function() {\n try {\n new Blob();\n return true\n } catch (e) {\n return false\n }\n })(),\n formData: 'FormData' in self,\n arrayBuffer: 'ArrayBuffer' in self\n };\n\n function isDataView(obj) {\n return obj && DataView.prototype.isPrototypeOf(obj)\n }\n\n if (support.arrayBuffer) {\n var viewClasses = [\n '[object Int8Array]',\n '[object Uint8Array]',\n '[object Uint8ClampedArray]',\n '[object Int16Array]',\n '[object Uint16Array]',\n '[object Int32Array]',\n '[object Uint32Array]',\n '[object Float32Array]',\n '[object Float64Array]'\n ];\n\n var isArrayBufferView =\n ArrayBuffer.isView ||\n function(obj) {\n return obj && viewClasses.indexOf(Object.prototype.toString.call(obj)) > -1\n };\n }\n\n function normalizeName(name) {\n if (typeof name !== 'string') {\n name = String(name);\n }\n if (/[^a-z0-9\\-#$%&'*+.^_`|~]/i.test(name)) {\n throw new TypeError('Invalid character in header field name')\n }\n return name.toLowerCase()\n }\n\n function normalizeValue(value) {\n if (typeof value !== 'string') {\n value = String(value);\n }\n return value\n }\n\n // Build a destructive iterator for the value list\n function iteratorFor(items) {\n var iterator = {\n next: function() {\n var value = items.shift();\n return {done: value === undefined, value: value}\n }\n };\n\n if (support.iterable) {\n iterator[Symbol.iterator] = function() {\n return iterator\n };\n }\n\n return iterator\n }\n\n function Headers(headers) {\n this.map = {};\n\n if (headers instanceof Headers) {\n headers.forEach(function(value, name) {\n this.append(name, value);\n }, this);\n } else if (Array.isArray(headers)) {\n headers.forEach(function(header) {\n this.append(header[0], header[1]);\n }, this);\n } else if (headers) {\n Object.getOwnPropertyNames(headers).forEach(function(name) {\n this.append(name, headers[name]);\n }, this);\n }\n }\n\n Headers.prototype.append = function(name, value) {\n name = normalizeName(name);\n value = normalizeValue(value);\n var oldValue = this.map[name];\n this.map[name] = oldValue ? oldValue + ', ' + value : value;\n };\n\n Headers.prototype['delete'] = function(name) {\n delete this.map[normalizeName(name)];\n };\n\n Headers.prototype.get = function(name) {\n name = normalizeName(name);\n return this.has(name) ? this.map[name] : null\n };\n\n Headers.prototype.has = function(name) {\n return this.map.hasOwnProperty(normalizeName(name))\n };\n\n Headers.prototype.set = function(name, value) {\n this.map[normalizeName(name)] = normalizeValue(value);\n };\n\n Headers.prototype.forEach = function(callback, thisArg) {\n for (var name in this.map) {\n if (this.map.hasOwnProperty(name)) {\n callback.call(thisArg, this.map[name], name, this);\n }\n }\n };\n\n Headers.prototype.keys = function() {\n var items = [];\n this.forEach(function(value, name) {\n items.push(name);\n });\n return iteratorFor(items)\n };\n\n Headers.prototype.values = function() {\n var items = [];\n this.forEach(function(value) {\n items.push(value);\n });\n return iteratorFor(items)\n };\n\n Headers.prototype.entries = function() {\n var items = [];\n this.forEach(function(value, name) {\n items.push([name, value]);\n });\n return iteratorFor(items)\n };\n\n if (support.iterable) {\n Headers.prototype[Symbol.iterator] = Headers.prototype.entries;\n }\n\n function consumed(body) {\n if (body.bodyUsed) {\n return Promise.reject(new TypeError('Already read'))\n }\n body.bodyUsed = true;\n }\n\n function fileReaderReady(reader) {\n return new Promise(function(resolve, reject) {\n reader.onload = function() {\n resolve(reader.result);\n };\n reader.onerror = function() {\n reject(reader.error);\n };\n })\n }\n\n function readBlobAsArrayBuffer(blob) {\n var reader = new FileReader();\n var promise = fileReaderReady(reader);\n reader.readAsArrayBuffer(blob);\n return promise\n }\n\n function readBlobAsText(blob) {\n var reader = new FileReader();\n var promise = fileReaderReady(reader);\n reader.readAsTLine truncated
|
||||
"mappings": ";;;;;;AAAA;AAAA;AAAA,WAAO,UAAU,OAAO;AAAA;AAAA;;;ACAxB;AAAA;AAAA,QAAI,SAAS,OAAO,SAAS,cAAc,OAAO;AAClD,QAAI,WAAY,WAAY;AAC5B,eAAS,IAAI;AACb,aAAK,QAAQ;AACb,aAAK,eAAe,OAAO;AAAA,MAC3B;AACA,QAAE,YAAY;AACd,aAAO,IAAI,EAAE;AAAA,IACb,EAAG;AACH,KAAC,SAASA,OAAM;AAEhB,UAAI,aAAc,SAAUC,UAAS;AAEnC,YAAI,UAAU;AAAA,UACZ,cAAc,qBAAqBD;AAAA,UACnC,UAAU,YAAYA,SAAQ,cAAc;AAAA,UAC5C,MACE,gBAAgBA,SAChB,UAAUA,SACT,WAAW;AACV,gBAAI;AACF,kBAAI,KAAK;AACT,qBAAO;AAAA,YACT,SAAS,GAAG;AACV,qBAAO;AAAA,YACT;AAAA,UACF,EAAG;AAAA,UACL,UAAU,cAAcA;AAAA,UACxB,aAAa,iBAAiBA;AAAA,QAChC;AAEA,iBAAS,WAAW,KAAK;AACvB,iBAAO,OAAO,SAAS,UAAU,cAAc,GAAG;AAAA,QACpD;AAEA,YAAI,QAAQ,aAAa;AACvB,cAAI,cAAc;AAAA,YAChB;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,UACF;AAEA,cAAI,oBACF,YAAY,UACZ,SAAS,KAAK;AACZ,mBAAO,OAAO,YAAY,QAAQ,OAAO,UAAU,SAAS,KAAK,GAAG,CAAC,IAAI;AAAA,UAC3E;AAAA,QACJ;AAEA,iBAAS,cAAc,MAAM;AAC3B,cAAI,OAAO,SAAS,UAAU;AAC5B,mBAAO,OAAO,IAAI;AAAA,UACpB;AACA,cAAI,4BAA4B,KAAK,IAAI,GAAG;AAC1C,kBAAM,IAAI,UAAU,wCAAwC;AAAA,UAC9D;AACA,iBAAO,KAAK,YAAY;AAAA,QAC1B;AAEA,iBAAS,eAAe,OAAO;AAC7B,cAAI,OAAO,UAAU,UAAU;AAC7B,oBAAQ,OAAO,KAAK;AAAA,UACtB;AACA,iBAAO;AAAA,QACT;AAGA,iBAAS,YAAY,OAAO;AAC1B,cAAI,WAAW;AAAA,YACb,MAAM,WAAW;AACf,kBAAI,QAAQ,MAAM,MAAM;AACxB,qBAAO,EAAC,MAAM,UAAU,QAAW,MAAY;AAAA,YACjD;AAAA,UACF;AAEA,cAAI,QAAQ,UAAU;AACpB,qBAAS,OAAO,QAAQ,IAAI,WAAW;AACrC,qBAAO;AAAA,YACT;AAAA,UACF;AAEA,iBAAO;AAAA,QACT;AAEA,iBAAS,QAAQ,SAAS;AACxB,eAAK,MAAM,CAAC;AAEZ,cAAI,mBAAmB,SAAS;AAC9B,oBAAQ,QAAQ,SAAS,OAAO,MAAM;AACpC,mBAAK,OAAO,MAAM,KAAK;AAAA,YACzB,GAAG,IAAI;AAAA,UACT,WAAW,MAAM,QAAQ,OAAO,GAAG;AACjC,oBAAQ,QAAQ,SAAS,QAAQ;AAC/B,mBAAK,OAAO,OAAO,CAAC,GAAG,OAAO,CAAC,CAAC;AAAA,YAClC,GAAG,IAAI;AAAA,UACT,WAAW,SAAS;AAClB,mBAAO,oBAAoB,OAAO,EAAE,QAAQ,SAAS,MAAM;AACzD,mBAAK,OAAO,MAAM,QAAQ,IAAI,CAAC;AAAA,YACjC,GAAG,IAAI;AAAA,UACT;AAAA,QACF;AAEA,gBAAQ,UAAU,SAAS,SAAS,MAAM,OAAO;AAC/C,iBAAO,cAAc,IAAI;AACzB,kBAAQ,eAAe,KAAK;AAC5B,cAAI,WAAW,KAAK,IAAI,IAAI;AAC5B,eAAK,IAAI,IAAI,IAAI,WAAW,WAAW,OAAO,QAAQ;AAAA,QACxD;AAEA,gBAAQ,UAAU,QAAQ,IAAI,SAAS,MAAM;AAC3C,iBAAO,KAAK,IAAI,cAAc,IAAI,CAAC;AAAA,QACrC;AAEA,gBAAQ,UAAU,MAAM,SAAS,MAAM;AACrC,iBAAO,cAAc,IAAI;AACzB,iBAAO,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI;AAAA,QAC3C;AAEA,gBAAQ,UAAU,MAAM,SAAS,MAAM;AACrC,iBAAO,KAAK,IAAI,eAAe,cAAc,IAAI,CAAC;AAAA,QACpD;AAEA,gBAAQ,UAAU,MAAM,SAAS,MAAM,OAAO;AAC5C,eAAK,IAAI,cAAc,IAAI,CAAC,IAAI,eAAe,KAAK;AAAA,QACtD;AAEA,gBAAQ,UAAU,UAAU,SAAS,UAAU,SAAS;AACtD,mBAAS,QAAQ,KAAK,KAAK;AACzB,gBAAI,KAAK,IAAI,eAAe,IAAI,GAAG;AACjC,uBAAS,KAAK,SAAS,KAAK,IAAI,IAAI,GAAG,MAAM,IAAI;AAAA,YACnD;AAAA,UACF;AAAA,QACF;AAEA,gBAAQ,UAAU,OAAO,WAAW;AAClC,cAAI,QAAQ,CAAC;AACb,eAAK,QAAQ,SAAS,OAAO,MAAM;AACjC,kBAAM,KAAK,IAAI;AAAA,UACjB,CAAC;AACD,iBAAO,YAAY,KAAK;AAAA,QAC1B;AAEA,gBAAQ,UAAU,SAAS,WAAW;AACpC,cAAI,QAAQ,CAAC;AACb,eAAK,QAAQ,SAAS,OAAO;AAC3B,kBAAM,KAAK,KAAK;AAAA,UAClB,CAAC;AACD,iBAAO,YAAY,KAAK;AAAA,QAC1B;AAEA,gBAAQ,UAAU,UAAU,WAAW;AACrC,cAAI,QAAQ,CAAC;AACb,eAAK,QAAQ,SAAS,OAAO,MAAM;AACjC,kBAAM,KAAK,CAAC,MAAM,KAAK,CAAC;AAAA,UAC1B,CAAC;AACD,iBAAO,YAAY,KAAK;AAAA,QAC1B;AAEA,YAAI,QAAQ,UAAU;AACpB,kBAAQ,UAAU,OAAO,QAAQ,IAAI,QAAQ,UAAU;AAAA,QACzD;AAEA,iBAAS,SAAS,MAAM;AACtB,cAAI,KAAK,UAAU;AACjB,mBAAO,QAAQ,OAAO,IAAI,UAAU,cAAc,CAAC;AAAA,UACrD;AACA,eAAK,WAAW;AAAA,QAClB;AAEA,iBAAS,gBAAgB,QAAQ;AAC/B,iBAAO,IAAI,QAAQ,SAAS,SAAS,QAAQ;AAC3C,mBAAO,SAAS,WAAW;AACzB,sBAAQ,OAAO,MAAM;AAAA,YACvB;AACA,mBAAO,UAAU,WAAW;AAC1B,qBAAO,OAAO,KAAK;AAAA,YACrB;AAAA,UACF,CAAC;AAAA,QACH;AAEA,iBAAS,sBAAsB,MAAM;AACnC,cAAI,SAAS,IAAI,WAAW;AAC5B,cAAI,UAAU,gBAAgB,MAAM;AACpC,iBAAO,kBAAkB,IAAI;AAC7B,iBAAO;AAAA,QACT;AAEA,iBAAS,eAAe,MAAM;AAC5B,cAAI,SAAS,IAAI,WAAW;AAC5B,cAAI,UAAU,gBAAgB,MAAM;AACpC,iBAAO,WAAW,IAAI;AACtB,iBAAO;AAAA,QACT;AAEA,iBAAS,sBAAsB,KAAK;AAClC,cAAI,OAAO,IAAI,WAAW,GAAG;AAC7B,cAAI,QAAQ,IAAI,MAAM,KAAK,MAAM;AAEjC,mBAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;AACpC,kBAAM,CAAC,IAAI,OAAO,aAAa,KAAK,CAAC,CAAC;AAAA,UACxC;AACA,iBAAO,MAAM,KAAK,EAAE;AAAA,QACtB;AAEA,iBAAS,YAAY,KAAK;AACxB,cAAI,IAAI,OAAO;AACb,mBAAO,IAAI,MAAM,CAAC;AAAA,UACpB,OAAO;AACL,gBAAI,OAAO,IAAI,WAAW,IAAI,UAAU;AACxC,iBAAK,IAAI,IAAI,WAAW,GAAG,CAAC;AAC5B,mBAAO,KAAK;AAAA,UACd;AAAA,QACF;AAEA,iBAAS,OAAO;AACd,eAAK,WAAW;AAEhB,eAAK,YAAY,SAAS,MAAM;AAC9B,iBAAK,YAAY;AACjB,gBAAI,CAAC,MAAM;AACT,mBAAK,YAAY;AAAA,YACnB,WAAW,OAAO,SAAS,UAAU;AACnC,mBAAK,YAAY;AAAA,YACnB,WAAW,QAAQ,QAAQ,KAAK,UAAU,cAAc,IAAI,GAAG;AAC7D,mBAAK,YAAY;AAAA,YACnB,WAAW,QAAQ,YAAY,SAAS,UAAU,cAAc,IAAI,GAAG;AACrE,mBAAK,gBAAgB;AAAA,YACvB,WAAW,QAAQ,gBAAgB,gBAAgB,UAAU,cAAc,IAAI,GAAG;AAChF,mBAAK,YAAY,KAAK,SAAS;AAAA,YACjC,WAAW,QAAQ,eAAe,QAAQ,QAAQ,WAAW,IAAI,GAAG;AAClE,mBAAK,mBAAmB,YAAY,KAAK,MAAM;AAE/C,mBAAK,YAAY,IAAI,KAAK,CAAC,KAAK,gBAAgB,CAAC;AAAA,YACnD,WAAW,QAAQ,gBAAgB,YAAY,UAAU,cAAc,IAAI,KAAK,kBAAkB,IAAI,IAAI;AACxG,mBAAK,mBAAmB,YAAY,IAAI;AAAA,YAC1C,OAAO;AACL,mBAAK,YAAY,OAAO,OAAO,UAAU,SAAS,KAAK,IAAI;AAAA,YAC7D;AAEA,gBAAI,CAAC,KAAK,QAAQ,IAAI,cAAc,GAAG;AACrC,kBAAI,OAAO,SAAS,UAAU;AAC5B,qBAAK,QAAQ,IAAI,gBAAgB,0BAA0B;AAAA,cAC7D,WAAW,KAAK,aAAa,KAAK,UAAU,MAAM;AAChD,qBAAK,QAAQ,IAAI,gBAAgB,KAAK,UAAU,IAAI;AAAA,cACtD,WAAW,QAAQ,gBAAgB,gBAAgB,UAAU,cAAc,IAAI,GAAG;AAChF,qBAAKLine truncated
|
||||
"names": ["self", "exports", "fetch", "_b", "_a"]
|
||||
}
|
||||
+1
-1
@@ -256,7 +256,7 @@ import {
|
||||
weeksToDays,
|
||||
yearsToMonths,
|
||||
yearsToQuarters
|
||||
} from "./chunk-FSI7PPCM.js";
|
||||
} from "./chunk-SJKHQ62W.js";
|
||||
import "./chunk-G3PMV62Z.js";
|
||||
export {
|
||||
add,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": ["../../date-fns/esm/locale/af/_lib/formatDistance/index.js", "../../date-fns/esm/locale/af/_lib/formatLong/index.js", "../../date-fns/esm/locale/af/_lib/formatRelative/index.js", "../../date-fns/esm/locale/af/_lib/localize/index.js", "../../date-fns/esm/locale/af/_lib/match/index.js", "../../date-fns/esm/locale/af/index.js", "../../date-fns/esm/locale/ar/_lib/formatDistance/index.js", "../../date-fns/esm/locale/ar/_lib/formatLong/index.js", "../../date-fns/esm/locale/ar/_lib/formatRelative/index.js", "../../date-fns/esm/locale/ar/_lib/localize/index.js", "../../date-fns/esm/locale/ar/_lib/match/index.js", "../../date-fns/esm/locale/ar/index.js", "../../date-fns/esm/locale/ar-DZ/_lib/formatDistance/index.js", "../../date-fns/esm/locale/ar-DZ/_lib/formatLong/index.js", "../../date-fns/esm/locale/ar-DZ/_lib/formatRelative/index.js", "../../date-fns/esm/locale/ar-DZ/_lib/localize/index.js", "../../date-fns/esm/locale/ar-DZ/_lib/match/index.js", "../../date-fns/esm/locale/ar-DZ/index.js", "../../date-fns/esm/locale/ar-EG/_lib/formatDistance/index.js", "../../date-fns/esm/locale/ar-EG/_lib/formatLong/index.js", "../../date-fns/esm/locale/ar-EG/_lib/formatRelative/index.js", "../../date-fns/esm/locale/ar-EG/_lib/localize/index.js", "../../date-fns/esm/locale/ar-EG/_lib/match/index.js", "../../date-fns/esm/locale/ar-EG/index.js", "../../date-fns/esm/locale/ar-MA/_lib/formatDistance/index.js", "../../date-fns/esm/locale/ar-MA/_lib/formatLong/index.js", "../../date-fns/esm/locale/ar-MA/_lib/formatRelative/index.js", "../../date-fns/esm/locale/ar-MA/_lib/localize/index.js", "../../date-fns/esm/locale/ar-MA/_lib/match/index.js", "../../date-fns/esm/locale/ar-MA/index.js", "../../date-fns/esm/locale/ar-SA/_lib/formatDistance/index.js", "../../date-fns/esm/locale/ar-SA/_lib/formatLong/index.js", "../../date-fns/esm/locale/ar-SA/_lib/formatRelative/index.js", "../../date-fns/esm/locale/ar-SA/_lib/localize/index.js", "../../date-fns/esm/locale/ar-SA/_lib/match/index.js", "../../date-fns/esm/locale/ar-SA/index.js", "../../date-fns/esm/locale/ar-TN/_lib/formatDistance/index.js", "../../date-fns/esm/locale/ar-TN/_lib/formatLong/index.js", "../../date-fns/esm/locale/ar-TN/_lib/formatRelative/index.js", "../../date-fns/esm/locale/ar-TN/_lib/localize/index.js", "../../date-fns/esm/locale/ar-TN/_lib/match/index.js", "../../date-fns/esm/locale/ar-TN/index.js", "../../date-fns/esm/locale/az/_lib/formatDistance/index.js", "../../date-fns/esm/locale/az/_lib/formatLong/index.js", "../../date-fns/esm/locale/az/_lib/formatRelative/index.js", "../../date-fns/esm/locale/az/_lib/localize/index.js", "../../date-fns/esm/locale/az/_lib/match/index.js", "../../date-fns/esm/locale/az/index.js", "../../date-fns/esm/locale/be/_lib/formatDistance/index.js", "../../date-fns/esm/locale/be/_lib/formatLong/index.js", "../../date-fns/esm/_lib/isSameUTCWeek/index.js", "../../date-fns/esm/locale/be/_lib/formatRelative/index.js", "../../date-fns/esm/locale/be/_lib/localize/index.js", "../../date-fns/esm/locale/be/_lib/match/index.js", "../../date-fns/esm/locale/be/index.js", "../../date-fns/esm/locale/be-tarask/_lib/formatDistance/index.js", "../../date-fns/esm/locale/be-tarask/_lib/formatLong/index.js", "../../date-fns/esm/locale/be-tarask/_lib/formatRelative/index.js", "../../date-fns/esm/locale/be-tarask/_lib/localize/index.js", "../../date-fns/esm/locale/be-tarask/_lib/match/index.js", "../../date-fns/esm/locale/be-tarask/index.js", "../../date-fns/esm/locale/bg/_lib/formatDistance/index.js", "../../date-fns/esm/locale/bg/_lib/formatLong/index.js", "../../date-fns/esm/locale/bg/_lib/formatRelative/index.js", "../../date-fns/esm/locale/bg/_lib/localize/index.js", "../../date-fns/esm/locale/bg/_lib/match/index.js", "../../date-fns/esm/locale/bg/index.js", "../../date-fns/esm/locale/bn/_lib/localize/index.js", "../../date-fns/esm/locale/bn/_lib/formatDistance/index.js", "../../date-fns/esm/locale/bn/_lib/formatLong/index.js", "../../date-fns/esm/locale/bn/_lib/formatRelative/index.js", "../../date-fns/esm/locale/bn/_lib/match/index.js", "../../date-fns/esm/locale/bn/index.js", "../../date-fns/esm/locale/bs/_lib/formatDistance/index.js", "../../date-fns/esm/locale/bs/_lib/formatLong/index.js", "../../date-fns/esm/locale/bs/_lib/formatRelative/index.js", "../../date-fns/esm/locale/bs/_lib/localize/index.js", "../../date-fns/esm/locale/bs/_lib/match/index.js", "../../date-fns/esm/locale/bs/index.js", "../../date-fns/esm/locale/ca/_lib/formatDistance/index.js", "../../date-fns/esm/locale/ca/_lib/formatLong/index.js", "../../date-fns/esm/locale/ca/_lib/formatRelative/index.js", "../../date-fns/esm/locale/ca/_lib/localize/index.js", "../../date-fns/esm/locale/ca/_lib/match/index.js", "../../date-fns/esm/locale/ca/index.js", "../../date-fns/esm/locale/cs/_lib/formatDistance/index.js", "../../date-fns/esm/locale/cs/_lib/formatLong/index.js", "../../date-fns/esm/locale/cs/_lib/formatRelative/index.js", "../../date-fns/esm/locale/cs/_lib/localize/inLine truncated
|
||||
"sourcesContent": ["var formatDistanceLocale = {\r\n lessThanXSeconds: {\r\n one: \"minder as 'n sekonde\",\r\n other: 'minder as {{count}} sekondes'\r\n },\r\n xSeconds: {\r\n one: '1 sekonde',\r\n other: '{{count}} sekondes'\r\n },\r\n halfAMinute: \"'n halwe minuut\",\r\n lessThanXMinutes: {\r\n one: \"minder as 'n minuut\",\r\n other: 'minder as {{count}} minute'\r\n },\r\n xMinutes: {\r\n one: \"'n minuut\",\r\n other: '{{count}} minute'\r\n },\r\n aboutXHours: {\r\n one: 'ongeveer 1 uur',\r\n other: 'ongeveer {{count}} ure'\r\n },\r\n xHours: {\r\n one: '1 uur',\r\n other: '{{count}} ure'\r\n },\r\n xDays: {\r\n one: '1 dag',\r\n other: '{{count}} dae'\r\n },\r\n aboutXWeeks: {\r\n one: 'ongeveer 1 week',\r\n other: 'ongeveer {{count}} weke'\r\n },\r\n xWeeks: {\r\n one: '1 week',\r\n other: '{{count}} weke'\r\n },\r\n aboutXMonths: {\r\n one: 'ongeveer 1 maand',\r\n other: 'ongeveer {{count}} maande'\r\n },\r\n xMonths: {\r\n one: '1 maand',\r\n other: '{{count}} maande'\r\n },\r\n aboutXYears: {\r\n one: 'ongeveer 1 jaar',\r\n other: 'ongeveer {{count}} jaar'\r\n },\r\n xYears: {\r\n one: '1 jaar',\r\n other: '{{count}} jaar'\r\n },\r\n overXYears: {\r\n one: 'meer as 1 jaar',\r\n other: 'meer as {{count}} jaar'\r\n },\r\n almostXYears: {\r\n one: 'byna 1 jaar',\r\n other: 'byna {{count}} jaar'\r\n }\r\n};\r\nvar formatDistance = function formatDistance(token, count, options) {\r\n var result;\r\n var tokenValue = formatDistanceLocale[token];\r\n if (typeof tokenValue === 'string') {\r\n result = tokenValue;\r\n } else if (count === 1) {\r\n result = tokenValue.one;\r\n } else {\r\n result = tokenValue.other.replace('{{count}}', String(count));\r\n }\r\n if (options !== null && options !== void 0 && options.addSuffix) {\r\n if (options.comparison && options.comparison > 0) {\r\n return 'oor ' + result;\r\n } else {\r\n return result + ' gelede';\r\n }\r\n }\r\n return result;\r\n};\r\nexport default formatDistance;", "import buildFormatLongFn from \"../../../_lib/buildFormatLongFn/index.js\";\r\nvar dateFormats = {\r\n full: 'EEEE, d MMMM yyyy',\r\n long: 'd MMMM yyyy',\r\n medium: 'd MMM yyyy',\r\n short: 'yyyy/MM/dd'\r\n};\r\nvar timeFormats = {\r\n full: 'HH:mm:ss zzzz',\r\n long: 'HH:mm:ss z',\r\n medium: 'HH:mm:ss',\r\n short: 'HH:mm'\r\n};\r\nvar dateTimeFormats = {\r\n full: \"{{date}} 'om' {{time}}\",\r\n long: \"{{date}} 'om' {{time}}\",\r\n medium: '{{date}}, {{time}}',\r\n short: '{{date}}, {{time}}'\r\n};\r\nvar formatLong = {\r\n date: buildFormatLongFn({\r\n formats: dateFormats,\r\n defaultWidth: 'full'\r\n }),\r\n time: buildFormatLongFn({\r\n formats: timeFormats,\r\n defaultWidth: 'full'\r\n }),\r\n dateTime: buildFormatLongFn({\r\n formats: dateTimeFormats,\r\n defaultWidth: 'full'\r\n })\r\n};\r\nexport default formatLong;", "var formatRelativeLocale = {\r\n lastWeek: \"'verlede' eeee 'om' p\",\r\n yesterday: \"'gister om' p\",\r\n today: \"'vandag om' p\",\r\n tomorrow: \"'môre om' p\",\r\n nextWeek: \"eeee 'om' p\",\r\n other: 'P'\r\n};\r\nvar formatRelative = function formatRelative(token, _date, _baseDate, _options) {\r\n return formatRelativeLocale[token];\r\n};\r\nexport default formatRelative;", "import buildLocalizeFn from \"../../../_lib/buildLocalizeFn/index.js\";\r\nvar eraValues = {\r\n narrow: ['vC', 'nC'],\r\n abbreviated: ['vC', 'nC'],\r\n wide: ['voor Christus', 'na Christus']\r\n};\r\nvar quarterValues = {\r\n narrow: ['1', '2', '3', '4'],\r\n abbreviated: ['K1', 'K2', 'K3', 'K4'],\r\n wide: ['1ste kwartaal', '2de kwartaal', '3de kwartaal', '4de kwartaal']\r\n};\r\nvar monthValues = {\r\n narrow: ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D'],\r\n abbreviated: ['Jan', 'Feb', 'Mrt', 'Apr', 'Mei', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Des'],\r\n wide: ['Januarie', 'Februarie', 'Maart', 'April', 'Mei', 'Junie', 'Julie', 'Augustus', 'September', 'Oktober', 'November', 'Desember']\r\n};\r\nvar dayValues = {\r\n narrow: ['S', 'M', 'D', 'W', 'D', 'V', 'S'],\r\n short: ['So', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Sa'],\r\n abbreviated: ['Son', 'Maa', 'Din', 'Woe', 'Don', 'Vry', 'Sat'],\r\n wide: ['Sondag', 'Maandag', 'Dinsdag', 'Woensdag', 'Donderdag', 'Vrydag', 'Saterdag']\r\n};\r\nvar dayPeriodValues = {\r\n narrow: {\r\n am: 'vm',\r\n pm: 'nm',\r\n midnight: 'middernag',\r\n noon: 'middaguur',\r\n morning: 'oggend',\r\n afternoon: 'middag',\r\n evening: 'laat middag',\r\n night: 'aand'\r\n },\r\n abbreviated: {\r\n am: 'vm',\r\n pm: 'nm',\r\n midnight: 'middernag',\r\n noon: 'middaguur',\r\n morning: 'oggend',\r\n afternoon: 'middag',\r\n evening: 'laat middag',\r\n night: 'aand'\r\n },\r\n wide: {\r\n am: 'vm',\r\n pm: 'nm',\r\n midnight: 'middernag',\r\n noon: 'middaguur',\r\nLine truncated
|
||||
"sourcesContent": ["var formatDistanceLocale = {\n lessThanXSeconds: {\n one: \"minder as 'n sekonde\",\n other: 'minder as {{count}} sekondes'\n },\n xSeconds: {\n one: '1 sekonde',\n other: '{{count}} sekondes'\n },\n halfAMinute: \"'n halwe minuut\",\n lessThanXMinutes: {\n one: \"minder as 'n minuut\",\n other: 'minder as {{count}} minute'\n },\n xMinutes: {\n one: \"'n minuut\",\n other: '{{count}} minute'\n },\n aboutXHours: {\n one: 'ongeveer 1 uur',\n other: 'ongeveer {{count}} ure'\n },\n xHours: {\n one: '1 uur',\n other: '{{count}} ure'\n },\n xDays: {\n one: '1 dag',\n other: '{{count}} dae'\n },\n aboutXWeeks: {\n one: 'ongeveer 1 week',\n other: 'ongeveer {{count}} weke'\n },\n xWeeks: {\n one: '1 week',\n other: '{{count}} weke'\n },\n aboutXMonths: {\n one: 'ongeveer 1 maand',\n other: 'ongeveer {{count}} maande'\n },\n xMonths: {\n one: '1 maand',\n other: '{{count}} maande'\n },\n aboutXYears: {\n one: 'ongeveer 1 jaar',\n other: 'ongeveer {{count}} jaar'\n },\n xYears: {\n one: '1 jaar',\n other: '{{count}} jaar'\n },\n overXYears: {\n one: 'meer as 1 jaar',\n other: 'meer as {{count}} jaar'\n },\n almostXYears: {\n one: 'byna 1 jaar',\n other: 'byna {{count}} jaar'\n }\n};\nvar formatDistance = function formatDistance(token, count, options) {\n var result;\n var tokenValue = formatDistanceLocale[token];\n if (typeof tokenValue === 'string') {\n result = tokenValue;\n } else if (count === 1) {\n result = tokenValue.one;\n } else {\n result = tokenValue.other.replace('{{count}}', String(count));\n }\n if (options !== null && options !== void 0 && options.addSuffix) {\n if (options.comparison && options.comparison > 0) {\n return 'oor ' + result;\n } else {\n return result + ' gelede';\n }\n }\n return result;\n};\nexport default formatDistance;", "import buildFormatLongFn from \"../../../_lib/buildFormatLongFn/index.js\";\nvar dateFormats = {\n full: 'EEEE, d MMMM yyyy',\n long: 'd MMMM yyyy',\n medium: 'd MMM yyyy',\n short: 'yyyy/MM/dd'\n};\nvar timeFormats = {\n full: 'HH:mm:ss zzzz',\n long: 'HH:mm:ss z',\n medium: 'HH:mm:ss',\n short: 'HH:mm'\n};\nvar dateTimeFormats = {\n full: \"{{date}} 'om' {{time}}\",\n long: \"{{date}} 'om' {{time}}\",\n medium: '{{date}}, {{time}}',\n short: '{{date}}, {{time}}'\n};\nvar formatLong = {\n date: buildFormatLongFn({\n formats: dateFormats,\n defaultWidth: 'full'\n }),\n time: buildFormatLongFn({\n formats: timeFormats,\n defaultWidth: 'full'\n }),\n dateTime: buildFormatLongFn({\n formats: dateTimeFormats,\n defaultWidth: 'full'\n })\n};\nexport default formatLong;", "var formatRelativeLocale = {\n lastWeek: \"'verlede' eeee 'om' p\",\n yesterday: \"'gister om' p\",\n today: \"'vandag om' p\",\n tomorrow: \"'môre om' p\",\n nextWeek: \"eeee 'om' p\",\n other: 'P'\n};\nvar formatRelative = function formatRelative(token, _date, _baseDate, _options) {\n return formatRelativeLocale[token];\n};\nexport default formatRelative;", "import buildLocalizeFn from \"../../../_lib/buildLocalizeFn/index.js\";\nvar eraValues = {\n narrow: ['vC', 'nC'],\n abbreviated: ['vC', 'nC'],\n wide: ['voor Christus', 'na Christus']\n};\nvar quarterValues = {\n narrow: ['1', '2', '3', '4'],\n abbreviated: ['K1', 'K2', 'K3', 'K4'],\n wide: ['1ste kwartaal', '2de kwartaal', '3de kwartaal', '4de kwartaal']\n};\nvar monthValues = {\n narrow: ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D'],\n abbreviated: ['Jan', 'Feb', 'Mrt', 'Apr', 'Mei', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Des'],\n wide: ['Januarie', 'Februarie', 'Maart', 'April', 'Mei', 'Junie', 'Julie', 'Augustus', 'September', 'Oktober', 'November', 'Desember']\n};\nvar dayValues = {\n narrow: ['S', 'M', 'D', 'W', 'D', 'V', 'S'],\n short: ['So', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Sa'],\n abbreviated: ['Son', 'Maa', 'Din', 'Woe', 'Don', 'Vry', 'Sat'],\n wide: ['Sondag', 'Maandag', 'Dinsdag', 'Woensdag', 'Donderdag', 'Vrydag', 'Saterdag']\n};\nvar dayPeriodValues = {\n narrow: {\n am: 'vm',\n pm: 'nm',\n midnight: 'middernag',\n noon: 'middaguur',\n morning: 'oggend',\n afternoon: 'middag',\n evening: 'laat middag',\n night: 'aand'\n },\n abbreviated: {\n am: 'vm',\n pm: 'nm',\n midnight: 'middernag',\n noon: 'middaguur',\n morning: 'oggend',\n afternoon: 'middag',\n evening: 'laat middag',\n night: 'aand'\n },\n wide: {\n am: 'vm',\n pm: 'nm',\n midnight: 'middernag',\n noon: 'middaguur',\n morning: 'oggend',\n afternoon: 'middag',\n evening: 'laat middag',\n night: 'aand'\n }\n};\nvar formattingDayPeriodValues = {\n narrow: {\n am: 'vm',\n pm: 'nm',\n midnight: 'middernag',\n noon: 'uur die middag',\n morning: 'uur die oggend',\n afternoon: 'uur die middag',\n evening: 'uur die aand',\n night:Line truncated
|
||||
"mappings": ";;;;;;;;;;;;;;;;;AAAA,IAAI,uBAAuB;AAAA,EACzB,kBAAkB;AAAA,IAChB,KAAK;AAAA,IACL,OAAO;AAAA,EACT;AAAA,EACA,UAAU;AAAA,IACR,KAAK;AAAA,IACL,OAAO;AAAA,EACT;AAAA,EACA,aAAa;AAAA,EACb,kBAAkB;AAAA,IAChB,KAAK;AAAA,IACL,OAAO;AAAA,EACT;AAAA,EACA,UAAU;AAAA,IACR,KAAK;AAAA,IACL,OAAO;AAAA,EACT;AAAA,EACA,aAAa;AAAA,IACX,KAAK;AAAA,IACL,OAAO;AAAA,EACT;AAAA,EACA,QAAQ;AAAA,IACN,KAAK;AAAA,IACL,OAAO;AAAA,EACT;AAAA,EACA,OAAO;AAAA,IACL,KAAK;AAAA,IACL,OAAO;AAAA,EACT;AAAA,EACA,aAAa;AAAA,IACX,KAAK;AAAA,IACL,OAAO;AAAA,EACT;AAAA,EACA,QAAQ;AAAA,IACN,KAAK;AAAA,IACL,OAAO;AAAA,EACT;AAAA,EACA,cAAc;AAAA,IACZ,KAAK;AAAA,IACL,OAAO;AAAA,EACT;AAAA,EACA,SAAS;AAAA,IACP,KAAK;AAAA,IACL,OAAO;AAAA,EACT;AAAA,EACA,aAAa;AAAA,IACX,KAAK;AAAA,IACL,OAAO;AAAA,EACT;AAAA,EACA,QAAQ;AAAA,IACN,KAAK;AAAA,IACL,OAAO;AAAA,EACT;AAAA,EACA,YAAY;AAAA,IACV,KAAK;AAAA,IACL,OAAO;AAAA,EACT;AAAA,EACA,cAAc;AAAA,IACZ,KAAK;AAAA,IACL,OAAO;AAAA,EACT;AACF;AACA,IAAI,iBAAiB,SAASA,gBAAe,OAAO,OAAO,SAAS;AAClE,MAAI;AACJ,MAAI,aAAa,qBAAqB,KAAK;AAC3C,MAAI,OAAO,eAAe,UAAU;AAClC,aAAS;AAAA,EACX,WAAW,UAAU,GAAG;AACtB,aAAS,WAAW;AAAA,EACtB,OAAO;AACL,aAAS,WAAW,MAAM,QAAQ,aAAa,OAAO,KAAK,CAAC;AAAA,EAC9D;AACA,MAAI,YAAY,QAAQ,YAAY,UAAU,QAAQ,WAAW;AAC/D,QAAI,QAAQ,cAAc,QAAQ,aAAa,GAAG;AAChD,aAAO,SAAS;AAAA,IAClB,OAAO;AACL,aAAO,SAAS;AAAA,IAClB;AAAA,EACF;AACA,SAAO;AACT;AACA,IAAOC,0BAAQ;;;ACjFf,IAAI,cAAc;AAAA,EAChB,MAAM;AAAA,EACN,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,OAAO;AACT;AACA,IAAI,cAAc;AAAA,EAChB,MAAM;AAAA,EACN,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,OAAO;AACT;AACA,IAAI,kBAAkB;AAAA,EACpB,MAAM;AAAA,EACN,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,OAAO;AACT;AACA,IAAI,aAAa;AAAA,EACf,MAAM,kBAAkB;AAAA,IACtB,SAAS;AAAA,IACT,cAAc;AAAA,EAChB,CAAC;AAAA,EACD,MAAM,kBAAkB;AAAA,IACtB,SAAS;AAAA,IACT,cAAc;AAAA,EAChB,CAAC;AAAA,EACD,UAAU,kBAAkB;AAAA,IAC1B,SAAS;AAAA,IACT,cAAc;AAAA,EAChB,CAAC;AACH;AACA,IAAO,qBAAQ;;;ACjCf,IAAI,uBAAuB;AAAA,EACzB,UAAU;AAAA,EACV,WAAW;AAAA,EACX,OAAO;AAAA,EACP,UAAU;AAAA,EACV,UAAU;AAAA,EACV,OAAO;AACT;AACA,IAAI,iBAAiB,SAASC,gBAAe,OAAO,OAAO,WAAW,UAAU;AAC9E,SAAO,qBAAqB,KAAK;AACnC;AACA,IAAOC,0BAAQ;;;ACVf,IAAI,YAAY;AAAA,EACd,QAAQ,CAAC,MAAM,IAAI;AAAA,EACnB,aAAa,CAAC,MAAM,IAAI;AAAA,EACxB,MAAM,CAAC,iBAAiB,aAAa;AACvC;AACA,IAAI,gBAAgB;AAAA,EAClB,QAAQ,CAAC,KAAK,KAAK,KAAK,GAAG;AAAA,EAC3B,aAAa,CAAC,MAAM,MAAM,MAAM,IAAI;AAAA,EACpC,MAAM,CAAC,iBAAiB,gBAAgB,gBAAgB,cAAc;AACxE;AACA,IAAI,cAAc;AAAA,EAChB,QAAQ,CAAC,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,GAAG;AAAA,EACnE,aAAa,CAAC,OAAO,OAAO,OAAO,OAAO,OAAO,OAAO,OAAO,OAAO,OAAO,OAAO,OAAO,KAAK;AAAA,EAChG,MAAM,CAAC,YAAY,aAAa,SAAS,SAAS,OAAO,SAAS,SAAS,YAAY,aAAa,WAAW,YAAY,UAAU;AACvI;AACA,IAAI,YAAY;AAAA,EACd,QAAQ,CAAC,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,GAAG;AAAA,EAC1C,OAAO,CAAC,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,IAAI;AAAA,EAChD,aAAa,CAAC,OAAO,OAAO,OAAO,OAAO,OAAO,OAAO,KAAK;AAAA,EAC7D,MAAM,CAAC,UAAU,WAAW,WAAW,YAAY,aAAa,UAAU,UAAU;AACtF;AACA,IAAI,kBAAkB;AAAA,EACpB,QAAQ;AAAA,IACN,IAAI;AAAA,IACJ,IAAI;AAAA,IACJ,UAAU;AAAA,IACV,MAAM;AAAA,IACN,SAAS;AAAA,IACT,WAAW;AAAA,IACX,SAAS;AAAA,IACT,OAAO;AAAA,EACT;AAAA,EACA,aAAa;AAAA,IACX,IAAI;AAAA,IACJ,IAAI;AAAA,IACJ,UAAU;AAAA,IACV,MAAM;AAAA,IACN,SAAS;AAAA,IACT,WAAW;AAAA,IACX,SAAS;AAAA,IACT,OAAO;AAAA,EACT;AAAA,EACA,MAAM;AAAA,IACJ,IAAI;AAAA,IACJ,IAAI;AAAA,IACJ,UAAU;AAAA,IACV,MAAM;AAAA,IACN,SAAS;AAAA,IACT,WAAW;AAAA,IACX,SAAS;AAAA,IACT,OAAO;AAAA,EACT;AACF;AACA,IAAI,4BAA4B;AAAA,EAC9B,QAAQ;AAAA,IACN,IAAI;AAAA,IACJ,IAAI;AAAA,IACJ,UAAU;AAAA,IACV,MAAM;AAAA,IACN,SAAS;AAAA,IACT,WAAW;AAAA,IACX,SAAS;AAAA,IACT,OAAO;AAAA,EACT;AAAA,EACA,aAAa;AAAA,IACX,IAAI;AAAA,IACJ,IAAI;AAAA,IACJ,UAAU;AAAA,IACV,MAAM;AAAA,IACN,SAAS;AAAA,IACT,WAAW;AAAA,IACX,SAAS;AAAA,IACT,OAAO;AAAA,EACT;AAAA,EACA,MAAM;AAAA,IACJ,IAAI;AAAA,IACJ,IAAI;AAAA,IACJ,UAAU;AAAA,IACV,MAAM;AAAA,IACN,SAAS;AAAA,IACT,WAAW;AAAA,IACX,SAAS;AAAA,IACT,OAAO;AAAA,EACT;AACF;AACA,IAAI,gBAAgB,SAASC,eAAc,aAAa;AACtD,MAAI,SAAS,OAAO,WAAW;AAC/B,MAAI,SAAS,SAAS;AACtB,MAAI,SAAS,IAAI;AACf,YAAQ,QAAQ;AAAA,MACd,KAAK;AAAA,MACL,KAAK;AACH,eAAO,SAAS;AAAA,MAClB;AACE,eAAO,SAAS;AAAA,IACpB;AAAA,EACF;AACA,SAAO,SAAS;AAClB;AACA,IAAI,WAAW;AAAA,EACb;AAAA,EACA,KAAK,gBAAgB;AAAA,IACnB,QAAQ;AAAA,IACR,cAAc;AAAA,EAChB,CAAC;AAAA,EACD,SAAS,gBAAgB;AAAA,IACvB,QAAQ;AAAA,IACR,cAAc;AAAA,IACd,kBAAkB,SAAS,iBAAiB,SAAS;AACnD,aAAO,UAAU;AAAA,IACnB;AAAA,EACF,CAAC;AAAA,EACD,OAAO,gBAAgB;AAAA,IACrB,QAAQ;AAAA,IACR,cAAc;AAAA,EAChB,CAAC;AAAA,EACD,KAAK,gBAAgB;AAAA,IACnB,QAAQ;AAAA,IACR,cAAc;AAAA,EAChB,CAAC;AAAA,EACD,WAAW,gBAAgB;AAAA,IACzB,QAAQ;AAAA,IACR,cAAc;AAAA,IACd,kBAAkB;AAAA,IAClB,wBAAwB;AAAA,EAC1B,CAAC;AACH;AACA,IAAOC,oBAAQ;;;AC9Hf,IAAI,4BAA4B;AAChC,IAAI,4BAA4B;AAChC,IAAI,mBAAmB;AAAA,EACrB,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,MAAM;AACR;AACA,IAAI,mBAAmB;AAAA,EACrB,KAAK,CAAC,MAAM,IAAI;AAClB;AACA,IAAI,uBAAuB;AAAA,EACzB,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,MAAM;AACR;AACA,IAAI,uBAAuB;AAAA,EACzB,KAAK,CAAC,MAAM,MAAM,MAAM,IAAI;AAC9B;AACA,IAAI,qBAAqB;AAAA,EACvB,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,MAAM;AACR;AACA,IAAI,qBAAqB;AAAA,EACvB,QAAQ,CAAC,OAAO,OAAO,OAAO,OAAO,OAAO,OAAO,OAAO,OAAO,OAAO,OAAO,OAAO,KAAK;AAAA,EAC3F,KAAK,CAAC,SAAS,SAAS,SAAS,SAAS,SAAS,SAAS,SAAS,SAAS,SAAS,SAAS,SAAS,OAAO;AAClH;AACA,IAAI,mBAAmB;AAAA,EACrB,QAAQ;Line truncated
|
||||
"names": ["formatDistance", "formatDistance_default", "formatRelative", "formatRelative_default", "ordinalNumber", "localize_default", "valueCallback", "match_default", "formatDistance_default", "formatRelative_default", "localize_default", "match_default", "formatDistanceLocale", "formatDistance", "formatDistance_default", "dateFormats", "timeFormats", "dateTimeFormats", "formatLong", "formatLong_default", "formatRelativeLocale", "formatRelative", "formatRelative_default", "eraValues", "quarterValues", "monthValues", "dayValues", "dayPeriodValues", "formattingDayPeriodValues", "ordinalNumber", "localize", "argumentCallback", "localize_default", "matchOrdinalNumberPattern", "parseOrdinalNumberPattern", "matchEraPatterns", "parseEraPatterns", "matchQuarterPatterns", "parseQuarterPatterns", "matchMonthPatterns", "parseMonthPatterns", "matchDayPatterns", "parseDayPatterns", "matchDayPeriodPatterns", "parseDayPeriodPatterns", "match", "valueCallback", "match_default", "locale", "formatDistance_default", "formatLong_default", "formatRelative_default", "localize_default", "match_default", "formatDistanceLocale", "formatDistance", "formatDistance_default", "dateFormats", "timeFormats", "dateTimeFormats", "formatLong", "formatLong_default", "formatRelativeLocale", "formatRelative", "formatRelative_default", "eraValues", "quarterValues", "monthValues", "dayValues", "dayPeriodValues", "formattingDayPeriodValues", "ordinalNumber", "localize", "argumentCallback", "localize_default", "matchOrdinalNumberPattern", "parseOrdinalNumberPattern", "matchEraPatterns", "parseEraPatterns", "matchQuarterPatterns", "parseQuarterPatterns", "matchMonthPatterns", "parseMonthPatterns", "matchDayPatterns", "parseDayPatterns", "matchDayPeriodPatterns", "parseDayPeriodPatterns", "match", "valueCallback", "match_default", "locale", "formatDistance_default", "formatLong_default", "formatRelative_default", "localize_default", "match_default", "formatDistanceLocale", "formatDistance", "formatDistance_default", "dateFormats", "timeFormats", "dateTimeFormats", "formatLong", "formatLong_default", "formatRelativeLocale", "formatRelative", "formatRelative_default", "eraValues", "quarterValues", "monthValues", "dayValues", "dayPeriodValues", "formattingDayPeriodValues", "ordinalNumber", "localize", "argumentCallback", "localize_default", "matchOrdinalNumberPattern", "parseOrdinalNumberPattern", "matchEraPatterns", "parseEraPatterns", "matchQuarterPatterns", "parseQuarterPatterns", "matchMonthPatterns", "parseMonthPatterns", "matchDayPatterns", "parseDayPatterns", "matchDayPeriodPatterns", "parseDayPeriodPatterns", "match", "valueCallback", "match_default", "locale", "formatDistance_default", "formatLong_default", "formatRelative_default", "localize_default", "match_default", "formatDistanceLocale", "formatDistance", "formatDistance_default", "dateFormats", "timeFormats", "dateTimeFormats", "formatLong", "formatLong_default", "formatRelativeLocale", "formatRelative", "formatRelative_default", "eraValues", "quarterValues", "monthValues", "dayValues", "dayPeriodValues", "formattingDayPeriodValues", "ordinalNumber", "localize", "argumentCallback", "localize_default", "matchOrdinalNumberPattern", "parseOrdinalNumberPattern", "matchEraPatterns", "parseEraPatterns", "matchQuarterPatterns", "parseQuarterPatterns", "matchMonthPatterns", "parseMonthPatterns", "matchDayPatterns", "parseDayPatterns", "matchDayPeriodPatterns", "parseDayPeriodPatterns", "match", "valueCallback", "match_default", "locale", "formatDistance_default", "formatLong_default", "formatRelative_default", "localize_default", "match_default", "formatDistanceLocale", "formatDistance", "formatDistance_default", "dateFormats", "timeFormats", "dateTimeFormats", "formatLong", "formatLong_default", "formatRelativeLocale", "formatRelative", "formatRelative_default", "eraValues", "quarterValues", "monthValues", "dayValues", "dayPeriodValues", "formattingDayPeriodValues", "ordinalNumber", "localize", "argumentCallback", "localize_default", "matchOrdinalNumberPattern", "parseOrdinalNumberPattern", "matchEraPatterns", "parseEraPatterns", "matchQuarterPatterns", "parseQuarterPatterns", "matchMonthPatterns", "parseMonthPatterns", "matchDayPatterns", "parseDayPatterns", "matchDayPeriodPatterns", "parseDayPeriodPatterns", "match", "valueCallback", "match_default", "locale", "formatDistance_default", "formatLong_default", "formatRelative_default", "localize_default", "match_default", "formatDistanceLocale", "formatDistance", "formatDistance_default", "dateFormats", "timeFormats", "dateTimeFormats", "formatLong", "formatLong_default", "formatRelativeLocale", "formatRelative", "formatRelative_default", "eraValues", "quarterValues", "monthValues", "dayValues", "dayPeriodValues", "formattingDayPeriodValues", "ordinalNumber", "localize", "argumentCallback", "localize_default", "matchOrdinalNumberPattern", "parseOrdinalNumberPattern", "matchEraPatterns", "parseEraPatterns", "matchQuarterPatterns", "parseQuartLine truncated
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
+2
-2
@@ -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";
|
||||
|
||||
+1
-1
@@ -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": []
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": ["../../react-icons/fa/index.esm.js"],
|
||||
"sourcesContent": ["// THIS FILE IS AUTO GENERATED\r\nimport { GenIcon } from '../lib';\r\nexport function Fa500Px (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 448 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M103.3 344.3c-6.5-14.2-6.9-18.3 7.4-23.1 25.6-8 8 9.2 43.2 49.2h.3v-93.9c1.2-50.2 44-92.2 97.7-92.2 53.9 0 97.7 43.5 97.7 96.8 0 63.4-60.8 113.2-128.5 93.3-10.5-4.2-2.1-31.7 8.5-28.6 53 0 89.4-10.1 89.4-64.4 0-61-77.1-89.6-116.9-44.6-23.5 26.4-17.6 42.1-17.6 157.6 50.7 31 118.3 22 160.4-20.1 24.8-24.8 38.5-58 38.5-93 0-35.2-13.8-68.2-38.8-93.3-24.8-24.8-57.8-38.5-93.3-38.5s-68.8 13.8-93.5 38.5c-.3.3-16 16.5-21.2 23.9l-.5.6c-3.3 4.7-6.3 9.1-20.1 6.1-6.9-1.7-14.3-5.8-14.3-11.8V20c0-5 3.9-10.5 10.5-10.5h241.3c8.3 0 8.3 11.6 8.3 15.1 0 3.9 0 15.1-8.3 15.1H130.3v132.9h.3c104.2-109.8 282.8-36 282.8 108.9 0 178.1-244.8 220.3-310.1 62.8zm63.3-260.8c-.5 4.2 4.6 24.5 14.6 20.6C306 56.6 384 144.5 390.6 144.5c4.8 0 22.8-15.3 14.3-22.8-93.2-89-234.5-57-238.3-38.2zM393 414.7C283 524.6 94 475.5 61 310.5c0-12.2-30.4-7.4-28.9 3.3 24 173.4 246 256.9 381.6 121.3 6.9-7.8-12.6-28.4-20.7-20.4zM213.6 306.6c0 4 4.3 7.3 5.5 8.5 3 3 6.1 4.4 8.5 4.4 3.8 0 2.6.2 22.3-19.5 19.6 19.3 19.1 19.5 22.3 19.5 5.4 0 18.5-10.4 10.7-18.2L265.6 284l18.2-18.2c6.3-6.8-10.1-21.8-16.2-15.7L249.7 268c-18.6-18.8-18.4-19.5-21.5-19.5-5 0-18 11.7-12.4 17.3L234 284c-18.1 17.9-20.4 19.2-20.4 22.6z\"}}]})(props);\r\n};\r\nexport function FaAccessibleIcon (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 448 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M423.9 255.8L411 413.1c-3.3 40.7-63.9 35.1-60.6-4.9l10-122.5-41.1 2.3c10.1 20.7 15.8 43.9 15.8 68.5 0 41.2-16.1 78.7-42.3 106.5l-39.3-39.3c57.9-63.7 13.1-167.2-74-167.2-25.9 0-49.5 9.9-67.2 26L73 243.2c22-20.7 50.1-35.1 81.4-40.2l75.3-85.7-42.6-24.8-51.6 46c-30 26.8-70.6-18.5-40.5-45.4l68-60.7c9.8-8.8 24.1-10.2 35.5-3.6 0 0 139.3 80.9 139.5 81.1 16.2 10.1 20.7 36 6.1 52.6L285.7 229l106.1-5.9c18.5-1.1 33.6 14.4 32.1 32.7zm-64.9-154c28.1 0 50.9-22.8 50.9-50.9C409.9 22.8 387.1 0 359 0c-28.1 0-50.9 22.8-50.9 50.9 0 28.1 22.8 50.9 50.9 50.9zM179.6 456.5c-80.6 0-127.4-90.6-82.7-156.1l-39.7-39.7C36.4 287 24 320.3 24 356.4c0 130.7 150.7 201.4 251.4 122.5l-39.7-39.7c-16 10.9-35.3 17.3-56.1 17.3z\"}}]})(props);\r\n};\r\nexport function FaAccusoft (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 640 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M322.1 252v-1l-51.2-65.8s-12 1.6-25 15.1c-9 9.3-242.1 239.1-243.4 240.9-7 10 1.6 6.8 15.7 1.7.8 0 114.5-36.6 114.5-36.6.5-.6-.1-.1.6-.6-.4-5.1-.8-26.2-1-27.7-.6-5.2 2.2-6.9 7-8.9l92.6-33.8c.6-.8 88.5-81.7 90.2-83.3zm160.1 120.1c13.3 16.1 20.7 13.3 30.8 9.3 3.2-1.2 115.4-47.6 117.8-48.9 8-4.3-1.7-16.7-7.2-23.4-2.1-2.5-205.1-245.6-207.2-248.3-9.7-12.2-14.3-12.9-38.4-12.8-10.2 0-106.8.5-116.5.6-19.2.1-32.9-.3-19.2 16.9C250 75 476.5 365.2 482.2 372.1zm152.7 1.6c-2.3-.3-24.6-4.7-38-7.2 0 0-115 50.4-117.5 51.6-16 7.3-26.9-3.2-36.7-14.6l-57.1-74c-5.4-.9-60.4-9.6-65.3-9.3-3.1.2-9.6.8-14.4 2.9-4.9 2.1-145.2 52.8-150.2 54.7-5.1 2-11.4 3.6-11.1 7.6.2 2.5 2 2.6 4.6 3.5 2.7.8 300.9 67.6 308 69.1 15.6 3.3 38.5 10.5 53.6 1.7 2.1-1.2 123.8-76.4 125.8-77.8 5.4-4 4.3-6.8-1.7-8.2z\"}}]})(props);\r\n};\r\nexport function FaAcquisitionsIncorporated (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 384 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M357.45 468.2c-1.2-7.7-1.3-7.6-9.6-7.6-99.8.2-111.8-2.4-112.7-2.6-12.3-1.7-20.6-10.5-21-23.1-.1-1.6-.2-71.6-1-129.1-.1-4.7 1.6-6.4 5.9-7.5 12.5-3 24.9-6.1 37.3-9.7 4.3-1.3 6.8-.2 8.4 3.5 4.5 10.3 8.8 20.6 13.2 30.9 1.6 3.7.1 4.4-3.4 4.4-10-.2-20-.1-30.4-.1v27h116c-1.4-9.5-2.7-18.1-4-27.5-7 0-13.8.4-20.4-.1-22.6-1.6-18.3-4.4-84-158.6-8.8-20.1-27.9-62.1-36.5-89.2-4.4-14 5.5-25.4 18.9-26.6 18.6-1.7 37.5-1.6 56.2-2 20.6-.4 41.2-.4 61.8-.5 3.1 0 4-1.4 4.3-4.3 1.2-9.8 2.7-19.5 4-29.2.8-5.3 1.6-10.7 2.4-16.1L23.75 0c-3.6 0-5.3 1.1-4.6 5.3 2.2 13.2-.8.8 6.4 45.3 63.4 0 71.8.9 101.8.5 12.3-.2 37 3.5 37.7 22.1.4 11.4-1.1 11.3-32.6 87.4-53.8 129.8-50.7 120.3-67.3 161-1.7 4.1-3.6 5.2-7.6 5.2-8.5-.2-17-.3-25.4.1-1.9.1-5.2 1.8-5.5 3.2-1.5 8.1-2.2 16.3-3.2 24.9h114.3v-27.6c-6.9 0-33.5.4-35.3-2.9 5.3-12.3 10.4-24.4 15.7-36.7 16.3 4 31.9 7.8 47.6 11.7 3.4.9 4.6 3 4.6 6.8-.1 42.9.1 85.9.2 128.8 0 10.2-5.5 19.1-14.9 23.1-6.5 2.7-3.3 3.4-121.4 2.4-5.3 0-7.1 2-7.6 6.8-1.5 12.9-2.9 25.9-5 38.8-.8 5 1.3 5.7 5.3 5.7 183.2.6-30.7 0 337.1 0-2.5-15-4.4-29.4-6.6-43.7zm-174.9-205.7c-13.3-4.2-26.6-8.2-39.9-12.5a44.53 44.53 0 0 1-5.8-2.9c17.2-44.3 34.2-88.1 51.3-132.1 7.5 2.4 7.9-.8 9.4 0 9.3 22.5 18.1 60.1 27 82.8 6.6 16.7 13 33.5 19.7 50.9a35.78 35.78 0 0 1-3.9 2.1c-13.1 3.9-26.4 7.5-39.4 11.7a27.66 27.66 0 0 1-18.4 0z\"}}]})(props);\r\n};\r\nexport function FaAdn (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 496 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M248 167.5l64.9 98.8H183.1l64.9-98.8zM496 256c0 1Line truncated
|
||||
"sourcesContent": ["// THIS FILE IS AUTO GENERATED\nimport { GenIcon } from '../lib';\nexport function Fa500Px (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 448 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M103.3 344.3c-6.5-14.2-6.9-18.3 7.4-23.1 25.6-8 8 9.2 43.2 49.2h.3v-93.9c1.2-50.2 44-92.2 97.7-92.2 53.9 0 97.7 43.5 97.7 96.8 0 63.4-60.8 113.2-128.5 93.3-10.5-4.2-2.1-31.7 8.5-28.6 53 0 89.4-10.1 89.4-64.4 0-61-77.1-89.6-116.9-44.6-23.5 26.4-17.6 42.1-17.6 157.6 50.7 31 118.3 22 160.4-20.1 24.8-24.8 38.5-58 38.5-93 0-35.2-13.8-68.2-38.8-93.3-24.8-24.8-57.8-38.5-93.3-38.5s-68.8 13.8-93.5 38.5c-.3.3-16 16.5-21.2 23.9l-.5.6c-3.3 4.7-6.3 9.1-20.1 6.1-6.9-1.7-14.3-5.8-14.3-11.8V20c0-5 3.9-10.5 10.5-10.5h241.3c8.3 0 8.3 11.6 8.3 15.1 0 3.9 0 15.1-8.3 15.1H130.3v132.9h.3c104.2-109.8 282.8-36 282.8 108.9 0 178.1-244.8 220.3-310.1 62.8zm63.3-260.8c-.5 4.2 4.6 24.5 14.6 20.6C306 56.6 384 144.5 390.6 144.5c4.8 0 22.8-15.3 14.3-22.8-93.2-89-234.5-57-238.3-38.2zM393 414.7C283 524.6 94 475.5 61 310.5c0-12.2-30.4-7.4-28.9 3.3 24 173.4 246 256.9 381.6 121.3 6.9-7.8-12.6-28.4-20.7-20.4zM213.6 306.6c0 4 4.3 7.3 5.5 8.5 3 3 6.1 4.4 8.5 4.4 3.8 0 2.6.2 22.3-19.5 19.6 19.3 19.1 19.5 22.3 19.5 5.4 0 18.5-10.4 10.7-18.2L265.6 284l18.2-18.2c6.3-6.8-10.1-21.8-16.2-15.7L249.7 268c-18.6-18.8-18.4-19.5-21.5-19.5-5 0-18 11.7-12.4 17.3L234 284c-18.1 17.9-20.4 19.2-20.4 22.6z\"}}]})(props);\n};\nexport function FaAccessibleIcon (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 448 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M423.9 255.8L411 413.1c-3.3 40.7-63.9 35.1-60.6-4.9l10-122.5-41.1 2.3c10.1 20.7 15.8 43.9 15.8 68.5 0 41.2-16.1 78.7-42.3 106.5l-39.3-39.3c57.9-63.7 13.1-167.2-74-167.2-25.9 0-49.5 9.9-67.2 26L73 243.2c22-20.7 50.1-35.1 81.4-40.2l75.3-85.7-42.6-24.8-51.6 46c-30 26.8-70.6-18.5-40.5-45.4l68-60.7c9.8-8.8 24.1-10.2 35.5-3.6 0 0 139.3 80.9 139.5 81.1 16.2 10.1 20.7 36 6.1 52.6L285.7 229l106.1-5.9c18.5-1.1 33.6 14.4 32.1 32.7zm-64.9-154c28.1 0 50.9-22.8 50.9-50.9C409.9 22.8 387.1 0 359 0c-28.1 0-50.9 22.8-50.9 50.9 0 28.1 22.8 50.9 50.9 50.9zM179.6 456.5c-80.6 0-127.4-90.6-82.7-156.1l-39.7-39.7C36.4 287 24 320.3 24 356.4c0 130.7 150.7 201.4 251.4 122.5l-39.7-39.7c-16 10.9-35.3 17.3-56.1 17.3z\"}}]})(props);\n};\nexport function FaAccusoft (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 640 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M322.1 252v-1l-51.2-65.8s-12 1.6-25 15.1c-9 9.3-242.1 239.1-243.4 240.9-7 10 1.6 6.8 15.7 1.7.8 0 114.5-36.6 114.5-36.6.5-.6-.1-.1.6-.6-.4-5.1-.8-26.2-1-27.7-.6-5.2 2.2-6.9 7-8.9l92.6-33.8c.6-.8 88.5-81.7 90.2-83.3zm160.1 120.1c13.3 16.1 20.7 13.3 30.8 9.3 3.2-1.2 115.4-47.6 117.8-48.9 8-4.3-1.7-16.7-7.2-23.4-2.1-2.5-205.1-245.6-207.2-248.3-9.7-12.2-14.3-12.9-38.4-12.8-10.2 0-106.8.5-116.5.6-19.2.1-32.9-.3-19.2 16.9C250 75 476.5 365.2 482.2 372.1zm152.7 1.6c-2.3-.3-24.6-4.7-38-7.2 0 0-115 50.4-117.5 51.6-16 7.3-26.9-3.2-36.7-14.6l-57.1-74c-5.4-.9-60.4-9.6-65.3-9.3-3.1.2-9.6.8-14.4 2.9-4.9 2.1-145.2 52.8-150.2 54.7-5.1 2-11.4 3.6-11.1 7.6.2 2.5 2 2.6 4.6 3.5 2.7.8 300.9 67.6 308 69.1 15.6 3.3 38.5 10.5 53.6 1.7 2.1-1.2 123.8-76.4 125.8-77.8 5.4-4 4.3-6.8-1.7-8.2z\"}}]})(props);\n};\nexport function FaAcquisitionsIncorporated (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 384 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M357.45 468.2c-1.2-7.7-1.3-7.6-9.6-7.6-99.8.2-111.8-2.4-112.7-2.6-12.3-1.7-20.6-10.5-21-23.1-.1-1.6-.2-71.6-1-129.1-.1-4.7 1.6-6.4 5.9-7.5 12.5-3 24.9-6.1 37.3-9.7 4.3-1.3 6.8-.2 8.4 3.5 4.5 10.3 8.8 20.6 13.2 30.9 1.6 3.7.1 4.4-3.4 4.4-10-.2-20-.1-30.4-.1v27h116c-1.4-9.5-2.7-18.1-4-27.5-7 0-13.8.4-20.4-.1-22.6-1.6-18.3-4.4-84-158.6-8.8-20.1-27.9-62.1-36.5-89.2-4.4-14 5.5-25.4 18.9-26.6 18.6-1.7 37.5-1.6 56.2-2 20.6-.4 41.2-.4 61.8-.5 3.1 0 4-1.4 4.3-4.3 1.2-9.8 2.7-19.5 4-29.2.8-5.3 1.6-10.7 2.4-16.1L23.75 0c-3.6 0-5.3 1.1-4.6 5.3 2.2 13.2-.8.8 6.4 45.3 63.4 0 71.8.9 101.8.5 12.3-.2 37 3.5 37.7 22.1.4 11.4-1.1 11.3-32.6 87.4-53.8 129.8-50.7 120.3-67.3 161-1.7 4.1-3.6 5.2-7.6 5.2-8.5-.2-17-.3-25.4.1-1.9.1-5.2 1.8-5.5 3.2-1.5 8.1-2.2 16.3-3.2 24.9h114.3v-27.6c-6.9 0-33.5.4-35.3-2.9 5.3-12.3 10.4-24.4 15.7-36.7 16.3 4 31.9 7.8 47.6 11.7 3.4.9 4.6 3 4.6 6.8-.1 42.9.1 85.9.2 128.8 0 10.2-5.5 19.1-14.9 23.1-6.5 2.7-3.3 3.4-121.4 2.4-5.3 0-7.1 2-7.6 6.8-1.5 12.9-2.9 25.9-5 38.8-.8 5 1.3 5.7 5.3 5.7 183.2.6-30.7 0 337.1 0-2.5-15-4.4-29.4-6.6-43.7zm-174.9-205.7c-13.3-4.2-26.6-8.2-39.9-12.5a44.53 44.53 0 0 1-5.8-2.9c17.2-44.3 34.2-88.1 51.3-132.1 7.5 2.4 7.9-.8 9.4 0 9.3 22.5 18.1 60.1 27 82.8 6.6 16.7 13 33.5 19.7 50.9a35.78 35.78 0 0 1-3.9 2.1c-13.1 3.9-26.4 7.5-39.4 11.7a27.66 27.66 0 0 1-18.4 0z\"}}]})(props);\n};\nexport function FaAdn (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 496 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M248 167.5l64.9 98.8H183.1l64.9-98.8zM496 256c0 136.9-111.1 248-248 248S0 392.9Line truncated
|
||||
"mappings": "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 truncated
|
||||
"names": []
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": ["../../react-icons/fa6/index.esm.js"],
|
||||
"sourcesContent": ["// THIS FILE IS AUTO GENERATED\r\nimport { GenIcon } from '../lib';\r\nexport function Fa42Group (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 640 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M320 96V416C341.011 416 361.818 411.861 381.23 403.821C400.641 395.78 418.28 383.995 433.138 369.138C447.995 354.28 459.78 336.641 467.821 317.23C475.861 297.818 480 277.011 480 256C480 234.989 475.861 214.182 467.821 194.771C459.78 175.359 447.995 157.72 433.138 142.863C418.28 128.005 400.641 116.22 381.23 108.179C361.818 100.139 341.011 96 320 96ZM0 256L160.002 416L320.003 256L160.002 96L0 256ZM480 256C480 277.011 484.138 297.818 492.179 317.23C500.219 336.643 512.005 354.28 526.862 369.138C541.72 383.995 559.357 395.781 578.77 403.821C598.182 411.862 618.989 416 640 416V96C597.565 96 556.869 112.858 526.862 142.863C496.857 172.869 480 213.565 480 256Z\"}}]})(props);\r\n};\r\nexport function Fa500Px (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 448 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M103.3 344.3c-6.5-14.2-6.9-18.3 7.4-23.1 25.6-8 8 9.2 43.2 49.2h.3v-93.9c1.2-50.2 44-92.2 97.7-92.2 53.9 0 97.7 43.5 97.7 96.8 0 63.4-60.8 113.2-128.5 93.3-10.5-4.2-2.1-31.7 8.5-28.6 53 0 89.4-10.1 89.4-64.4 0-61-77.1-89.6-116.9-44.6-23.5 26.4-17.6 42.1-17.6 157.6 50.7 31 118.3 22 160.4-20.1 24.8-24.8 38.5-58 38.5-93 0-35.2-13.8-68.2-38.8-93.3-24.8-24.8-57.8-38.5-93.3-38.5s-68.8 13.8-93.5 38.5c-.3.3-16 16.5-21.2 23.9l-.5.6c-3.3 4.7-6.3 9.1-20.1 6.1-6.9-1.7-14.3-5.8-14.3-11.8V20c0-5 3.9-10.5 10.5-10.5h241.3c8.3 0 8.3 11.6 8.3 15.1 0 3.9 0 15.1-8.3 15.1H130.3v132.9h.3c104.2-109.8 282.8-36 282.8 108.9 0 178.1-244.8 220.3-310.1 62.8zm63.3-260.8c-.5 4.2 4.6 24.5 14.6 20.6C306 56.6 384 144.5 390.6 144.5c4.8 0 22.8-15.3 14.3-22.8-93.2-89-234.5-57-238.3-38.2zM393 414.7C283 524.6 94 475.5 61 310.5c0-12.2-30.4-7.4-28.9 3.3 24 173.4 246 256.9 381.6 121.3 6.9-7.8-12.6-28.4-20.7-20.4zM213.6 306.6c0 4 4.3 7.3 5.5 8.5 3 3 6.1 4.4 8.5 4.4 3.8 0 2.6.2 22.3-19.5 19.6 19.3 19.1 19.5 22.3 19.5 5.4 0 18.5-10.4 10.7-18.2L265.6 284l18.2-18.2c6.3-6.8-10.1-21.8-16.2-15.7L249.7 268c-18.6-18.8-18.4-19.5-21.5-19.5-5 0-18 11.7-12.4 17.3L234 284c-18.1 17.9-20.4 19.2-20.4 22.6z\"}}]})(props);\r\n};\r\nexport function FaAccessibleIcon (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 448 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M423.9 255.8L411 413.1c-3.3 40.7-63.9 35.1-60.6-4.9l10-122.5-41.1 2.3c10.1 20.7 15.8 43.9 15.8 68.5 0 41.2-16.1 78.7-42.3 106.5l-39.3-39.3c57.9-63.7 13.1-167.2-74-167.2-25.9 0-49.5 9.9-67.2 26L73 243.2c22-20.7 50.1-35.1 81.4-40.2l75.3-85.7-42.6-24.8-51.6 46c-30 26.8-70.6-18.5-40.5-45.4l68-60.7c9.8-8.8 24.1-10.2 35.5-3.6 0 0 139.3 80.9 139.5 81.1 16.2 10.1 20.7 36 6.1 52.6L285.7 229l106.1-5.9c18.5-1.1 33.6 14.4 32.1 32.7zm-64.9-154c28.1 0 50.9-22.8 50.9-50.9C409.9 22.8 387.1 0 359 0c-28.1 0-50.9 22.8-50.9 50.9 0 28.1 22.8 50.9 50.9 50.9zM179.6 456.5c-80.6 0-127.4-90.6-82.7-156.1l-39.7-39.7C36.4 287 24 320.3 24 356.4c0 130.7 150.7 201.4 251.4 122.5l-39.7-39.7c-16 10.9-35.3 17.3-56.1 17.3z\"}}]})(props);\r\n};\r\nexport function FaAccusoft (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 640 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M322.1 252v-1l-51.2-65.8s-12 1.6-25 15.1c-9 9.3-242.1 239.1-243.4 240.9-7 10 1.6 6.8 15.7 1.7.8 0 114.5-36.6 114.5-36.6.5-.6-.1-.1.6-.6-.4-5.1-.8-26.2-1-27.7-.6-5.2 2.2-6.9 7-8.9l92.6-33.8c.6-.8 88.5-81.7 90.2-83.3zm160.1 120.1c13.3 16.1 20.7 13.3 30.8 9.3 3.2-1.2 115.4-47.6 117.8-48.9 8-4.3-1.7-16.7-7.2-23.4-2.1-2.5-205.1-245.6-207.2-248.3-9.7-12.2-14.3-12.9-38.4-12.8-10.2 0-106.8.5-116.5.6-19.2.1-32.9-.3-19.2 16.9C250 75 476.5 365.2 482.2 372.1zm152.7 1.6c-2.3-.3-24.6-4.7-38-7.2 0 0-115 50.4-117.5 51.6-16 7.3-26.9-3.2-36.7-14.6l-57.1-74c-5.4-.9-60.4-9.6-65.3-9.3-3.1.2-9.6.8-14.4 2.9-4.9 2.1-145.2 52.8-150.2 54.7-5.1 2-11.4 3.6-11.1 7.6.2 2.5 2 2.6 4.6 3.5 2.7.8 300.9 67.6 308 69.1 15.6 3.3 38.5 10.5 53.6 1.7 2.1-1.2 123.8-76.4 125.8-77.8 5.4-4 4.3-6.8-1.7-8.2z\"}}]})(props);\r\n};\r\nexport function FaAdn (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 496 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M248 167.5l64.9 98.8H183.1l64.9-98.8zM496 256c0 136.9-111.1 248-248 248S0 392.9 0 256 111.1 8 248 8s248 111.1 248 248zm-99.8 82.7L248 115.5 99.8 338.7h30.4l33.6-51.7h168.6l33.6 51.7h30.2z\"}}]})(props);\r\n};\r\nexport function FaAdversal (props) {\r\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 512 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M482.1 32H28.7C5.8 32 0 37.9 0 60.9v390.2C0 474.4 5.8 480 28.7 480h453.4c24.4 0 29.9-5.2 29.9-29.7V62.2c0-24.6-5.4-30.2-29.9-30.2zM178.4 220.3c-27.5-20.2-72.1-8.7-84.2 23.4-4.3 11.1-9.3 9.5-17.5 8.3-9.7-1.5-17.2-3.2-22.5-5.5-28.8-11.4 8.6-55.3 24.9-64.3 41.1-21.4 83.4-22.2 125.3-4.8 40.9 16.8 34.5 59.2 34.5 128.5 2.7 25.8-4.3 58.3 9.3 88.8 1Line truncated
|
||||
"sourcesContent": ["// THIS FILE IS AUTO GENERATED\nimport { GenIcon } from '../lib';\nexport function Fa42Group (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 640 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M320 96V416C341.011 416 361.818 411.861 381.23 403.821C400.641 395.78 418.28 383.995 433.138 369.138C447.995 354.28 459.78 336.641 467.821 317.23C475.861 297.818 480 277.011 480 256C480 234.989 475.861 214.182 467.821 194.771C459.78 175.359 447.995 157.72 433.138 142.863C418.28 128.005 400.641 116.22 381.23 108.179C361.818 100.139 341.011 96 320 96ZM0 256L160.002 416L320.003 256L160.002 96L0 256ZM480 256C480 277.011 484.138 297.818 492.179 317.23C500.219 336.643 512.005 354.28 526.862 369.138C541.72 383.995 559.357 395.781 578.77 403.821C598.182 411.862 618.989 416 640 416V96C597.565 96 556.869 112.858 526.862 142.863C496.857 172.869 480 213.565 480 256Z\"}}]})(props);\n};\nexport function Fa500Px (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 448 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M103.3 344.3c-6.5-14.2-6.9-18.3 7.4-23.1 25.6-8 8 9.2 43.2 49.2h.3v-93.9c1.2-50.2 44-92.2 97.7-92.2 53.9 0 97.7 43.5 97.7 96.8 0 63.4-60.8 113.2-128.5 93.3-10.5-4.2-2.1-31.7 8.5-28.6 53 0 89.4-10.1 89.4-64.4 0-61-77.1-89.6-116.9-44.6-23.5 26.4-17.6 42.1-17.6 157.6 50.7 31 118.3 22 160.4-20.1 24.8-24.8 38.5-58 38.5-93 0-35.2-13.8-68.2-38.8-93.3-24.8-24.8-57.8-38.5-93.3-38.5s-68.8 13.8-93.5 38.5c-.3.3-16 16.5-21.2 23.9l-.5.6c-3.3 4.7-6.3 9.1-20.1 6.1-6.9-1.7-14.3-5.8-14.3-11.8V20c0-5 3.9-10.5 10.5-10.5h241.3c8.3 0 8.3 11.6 8.3 15.1 0 3.9 0 15.1-8.3 15.1H130.3v132.9h.3c104.2-109.8 282.8-36 282.8 108.9 0 178.1-244.8 220.3-310.1 62.8zm63.3-260.8c-.5 4.2 4.6 24.5 14.6 20.6C306 56.6 384 144.5 390.6 144.5c4.8 0 22.8-15.3 14.3-22.8-93.2-89-234.5-57-238.3-38.2zM393 414.7C283 524.6 94 475.5 61 310.5c0-12.2-30.4-7.4-28.9 3.3 24 173.4 246 256.9 381.6 121.3 6.9-7.8-12.6-28.4-20.7-20.4zM213.6 306.6c0 4 4.3 7.3 5.5 8.5 3 3 6.1 4.4 8.5 4.4 3.8 0 2.6.2 22.3-19.5 19.6 19.3 19.1 19.5 22.3 19.5 5.4 0 18.5-10.4 10.7-18.2L265.6 284l18.2-18.2c6.3-6.8-10.1-21.8-16.2-15.7L249.7 268c-18.6-18.8-18.4-19.5-21.5-19.5-5 0-18 11.7-12.4 17.3L234 284c-18.1 17.9-20.4 19.2-20.4 22.6z\"}}]})(props);\n};\nexport function FaAccessibleIcon (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 448 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M423.9 255.8L411 413.1c-3.3 40.7-63.9 35.1-60.6-4.9l10-122.5-41.1 2.3c10.1 20.7 15.8 43.9 15.8 68.5 0 41.2-16.1 78.7-42.3 106.5l-39.3-39.3c57.9-63.7 13.1-167.2-74-167.2-25.9 0-49.5 9.9-67.2 26L73 243.2c22-20.7 50.1-35.1 81.4-40.2l75.3-85.7-42.6-24.8-51.6 46c-30 26.8-70.6-18.5-40.5-45.4l68-60.7c9.8-8.8 24.1-10.2 35.5-3.6 0 0 139.3 80.9 139.5 81.1 16.2 10.1 20.7 36 6.1 52.6L285.7 229l106.1-5.9c18.5-1.1 33.6 14.4 32.1 32.7zm-64.9-154c28.1 0 50.9-22.8 50.9-50.9C409.9 22.8 387.1 0 359 0c-28.1 0-50.9 22.8-50.9 50.9 0 28.1 22.8 50.9 50.9 50.9zM179.6 456.5c-80.6 0-127.4-90.6-82.7-156.1l-39.7-39.7C36.4 287 24 320.3 24 356.4c0 130.7 150.7 201.4 251.4 122.5l-39.7-39.7c-16 10.9-35.3 17.3-56.1 17.3z\"}}]})(props);\n};\nexport function FaAccusoft (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 640 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M322.1 252v-1l-51.2-65.8s-12 1.6-25 15.1c-9 9.3-242.1 239.1-243.4 240.9-7 10 1.6 6.8 15.7 1.7.8 0 114.5-36.6 114.5-36.6.5-.6-.1-.1.6-.6-.4-5.1-.8-26.2-1-27.7-.6-5.2 2.2-6.9 7-8.9l92.6-33.8c.6-.8 88.5-81.7 90.2-83.3zm160.1 120.1c13.3 16.1 20.7 13.3 30.8 9.3 3.2-1.2 115.4-47.6 117.8-48.9 8-4.3-1.7-16.7-7.2-23.4-2.1-2.5-205.1-245.6-207.2-248.3-9.7-12.2-14.3-12.9-38.4-12.8-10.2 0-106.8.5-116.5.6-19.2.1-32.9-.3-19.2 16.9C250 75 476.5 365.2 482.2 372.1zm152.7 1.6c-2.3-.3-24.6-4.7-38-7.2 0 0-115 50.4-117.5 51.6-16 7.3-26.9-3.2-36.7-14.6l-57.1-74c-5.4-.9-60.4-9.6-65.3-9.3-3.1.2-9.6.8-14.4 2.9-4.9 2.1-145.2 52.8-150.2 54.7-5.1 2-11.4 3.6-11.1 7.6.2 2.5 2 2.6 4.6 3.5 2.7.8 300.9 67.6 308 69.1 15.6 3.3 38.5 10.5 53.6 1.7 2.1-1.2 123.8-76.4 125.8-77.8 5.4-4 4.3-6.8-1.7-8.2z\"}}]})(props);\n};\nexport function FaAdn (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 496 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M248 167.5l64.9 98.8H183.1l64.9-98.8zM496 256c0 136.9-111.1 248-248 248S0 392.9 0 256 111.1 8 248 8s248 111.1 248 248zm-99.8 82.7L248 115.5 99.8 338.7h30.4l33.6-51.7h168.6l33.6 51.7h30.2z\"}}]})(props);\n};\nexport function FaAdversal (props) {\n return GenIcon({\"tag\":\"svg\",\"attr\":{\"viewBox\":\"0 0 512 512\"},\"child\":[{\"tag\":\"path\",\"attr\":{\"d\":\"M482.1 32H28.7C5.8 32 0 37.9 0 60.9v390.2C0 474.4 5.8 480 28.7 480h453.4c24.4 0 29.9-5.2 29.9-29.7V62.2c0-24.6-5.4-30.2-29.9-30.2zM178.4 220.3c-27.5-20.2-72.1-8.7-84.2 23.4-4.3 11.1-9.3 9.5-17.5 8.3-9.7-1.5-17.2-3.2-22.5-5.5-28.8-11.4 8.6-55.3 24.9-64.3 41.1-21.4 83.4-22.2 125.3-4.8 40.9 16.8 34.5 59.2 34.5 128.5 2.7 25.8-4.3 58.3 9.3 88.8 1.9 4.4.4 7.9-2.7 10.7-8.4 6.7-39.3 2Line truncated
|
||||
"mappings": "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 truncated
|
||||
"names": []
|
||||
}
|
||||
+2
-2
@@ -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";
|
||||
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
require_react
|
||||
} from "./chunk-QRULMDK5.js";
|
||||
} from "./chunk-DRWLMN53.js";
|
||||
import {
|
||||
__commonJS
|
||||
} from "./chunk-G3PMV62Z.js";
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": ["../../react/cjs/react-jsx-dev-runtime.development.js", "../../react/jsx-dev-runtime.js"],
|
||||
"sourcesContent": ["/**\r\n * @license React\r\n * react-jsx-dev-runtime.development.js\r\n *\r\n * Copyright (c) Facebook, Inc. and its affiliates.\r\n *\r\n * This source code is licensed under the MIT license found in the\r\n * LICENSE file in the root directory of this source tree.\r\n */\r\n\r\n'use strict';\r\n\r\nif (process.env.NODE_ENV !== \"production\") {\r\n (function() {\r\n'use strict';\r\n\r\nvar React = require('react');\r\n\r\n// ATTENTION\r\n// When adding new symbols to this file,\r\n// Please consider also adding to 'react-devtools-shared/src/backend/ReactSymbols'\r\n// The Symbol used to tag the ReactElement-like types.\r\nvar REACT_ELEMENT_TYPE = Symbol.for('react.element');\r\nvar REACT_PORTAL_TYPE = Symbol.for('react.portal');\r\nvar REACT_FRAGMENT_TYPE = Symbol.for('react.fragment');\r\nvar REACT_STRICT_MODE_TYPE = Symbol.for('react.strict_mode');\r\nvar REACT_PROFILER_TYPE = Symbol.for('react.profiler');\r\nvar REACT_PROVIDER_TYPE = Symbol.for('react.provider');\r\nvar REACT_CONTEXT_TYPE = Symbol.for('react.context');\r\nvar REACT_FORWARD_REF_TYPE = Symbol.for('react.forward_ref');\r\nvar REACT_SUSPENSE_TYPE = Symbol.for('react.suspense');\r\nvar REACT_SUSPENSE_LIST_TYPE = Symbol.for('react.suspense_list');\r\nvar REACT_MEMO_TYPE = Symbol.for('react.memo');\r\nvar REACT_LAZY_TYPE = Symbol.for('react.lazy');\r\nvar REACT_OFFSCREEN_TYPE = Symbol.for('react.offscreen');\r\nvar MAYBE_ITERATOR_SYMBOL = Symbol.iterator;\r\nvar FAUX_ITERATOR_SYMBOL = '@@iterator';\r\nfunction getIteratorFn(maybeIterable) {\r\n if (maybeIterable === null || typeof maybeIterable !== 'object') {\r\n return null;\r\n }\r\n\r\n var maybeIterator = MAYBE_ITERATOR_SYMBOL && maybeIterable[MAYBE_ITERATOR_SYMBOL] || maybeIterable[FAUX_ITERATOR_SYMBOL];\r\n\r\n if (typeof maybeIterator === 'function') {\r\n return maybeIterator;\r\n }\r\n\r\n return null;\r\n}\r\n\r\nvar ReactSharedInternals = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;\r\n\r\nfunction error(format) {\r\n {\r\n {\r\n for (var _len2 = arguments.length, args = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) {\r\n args[_key2 - 1] = arguments[_key2];\r\n }\r\n\r\n printWarning('error', format, args);\r\n }\r\n }\r\n}\r\n\r\nfunction printWarning(level, format, args) {\r\n // When changing this logic, you might want to also\r\n // update consoleWithStackDev.www.js as well.\r\n {\r\n var ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame;\r\n var stack = ReactDebugCurrentFrame.getStackAddendum();\r\n\r\n if (stack !== '') {\r\n format += '%s';\r\n args = args.concat([stack]);\r\n } // eslint-disable-next-line react-internal/safe-string-coercion\r\n\r\n\r\n var argsWithFormat = args.map(function (item) {\r\n return String(item);\r\n }); // Careful: RN currently depends on this prefix\r\n\r\n argsWithFormat.unshift('Warning: ' + format); // We intentionally don't use spread (or .apply) directly because it\r\n // breaks IE9: https://github.com/facebook/react/issues/13610\r\n // eslint-disable-next-line react-internal/no-production-logging\r\n\r\n Function.prototype.apply.call(console[level], console, argsWithFormat);\r\n }\r\n}\r\n\r\n// -----------------------------------------------------------------------------\r\n\r\nvar enableScopeAPI = false; // Experimental Create Event Handle API.\r\nvar enableCacheElement = false;\r\nvar enableTransitionTracing = false; // No known bugs, but needs performance testing\r\n\r\nvar enableLegacyHidden = false; // Enables unstable_avoidThisFallback feature in Fiber\r\n// stuff. Intended to enable React core members to more easily debug scheduling\r\n// issues in DEV builds.\r\n\r\nvar enableDebugTracing = false; // Track which Fiber(s) schedule render work.\r\n\r\nvar REACT_MODULE_REFERENCE;\r\n\r\n{\r\n REACT_MODULE_REFERENCE = Symbol.for('react.module.reference');\r\n}\r\n\r\nfunction isValidElementType(type) {\r\n if (typeof type === 'string' || typeof type === 'function') {\r\n return true;\r\n } // Note: typeof might be other than 'symbol' or 'number' (e.g. if it's a polyfill).\r\n\r\n\r\n if (type === REACT_FRAGMENT_TYPE || type === REACT_PROFILER_TYPE || enableDebugTracing || type === REACT_STRICT_MODE_TYPE || type === REACT_SUSPENSE_TYPE || type === REACT_SUSPENSE_LIST_TYPE || enableLegacyHidden || type === REACT_OFFSCREEN_TYPE || enableScopeAPI || enableCacheElement || enableTransitionTracing ) {\r\n return true;\r\n }\r\n\r\n if (typeof type === 'object' && type !== null) {\r\n if (type.$$typeof === REACT_LAZY_TYPE || type.$$typeof === REACT_MEMO_TYPE || type.$$typeof === REACT_PROVIDER_TYPE || type.$$typeof === REACT_CONTEXT_TYPE || type.$$typeof === REACT_FORWARD_REF_TYPE || // This needs to include all possible module reference object\r\n // types supported by any Flight configuration anywhere since\r\n // we don't know which Flight buLine truncated
|
||||
"sourcesContent": ["/**\n * @license React\n * react-jsx-dev-runtime.development.js\n *\n * Copyright (c) Facebook, Inc. and its affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n */\n\n'use strict';\n\nif (process.env.NODE_ENV !== \"production\") {\n (function() {\n'use strict';\n\nvar React = require('react');\n\n// ATTENTION\n// When adding new symbols to this file,\n// Please consider also adding to 'react-devtools-shared/src/backend/ReactSymbols'\n// The Symbol used to tag the ReactElement-like types.\nvar REACT_ELEMENT_TYPE = Symbol.for('react.element');\nvar REACT_PORTAL_TYPE = Symbol.for('react.portal');\nvar REACT_FRAGMENT_TYPE = Symbol.for('react.fragment');\nvar REACT_STRICT_MODE_TYPE = Symbol.for('react.strict_mode');\nvar REACT_PROFILER_TYPE = Symbol.for('react.profiler');\nvar REACT_PROVIDER_TYPE = Symbol.for('react.provider');\nvar REACT_CONTEXT_TYPE = Symbol.for('react.context');\nvar REACT_FORWARD_REF_TYPE = Symbol.for('react.forward_ref');\nvar REACT_SUSPENSE_TYPE = Symbol.for('react.suspense');\nvar REACT_SUSPENSE_LIST_TYPE = Symbol.for('react.suspense_list');\nvar REACT_MEMO_TYPE = Symbol.for('react.memo');\nvar REACT_LAZY_TYPE = Symbol.for('react.lazy');\nvar REACT_OFFSCREEN_TYPE = Symbol.for('react.offscreen');\nvar MAYBE_ITERATOR_SYMBOL = Symbol.iterator;\nvar FAUX_ITERATOR_SYMBOL = '@@iterator';\nfunction getIteratorFn(maybeIterable) {\n if (maybeIterable === null || typeof maybeIterable !== 'object') {\n return null;\n }\n\n var maybeIterator = MAYBE_ITERATOR_SYMBOL && maybeIterable[MAYBE_ITERATOR_SYMBOL] || maybeIterable[FAUX_ITERATOR_SYMBOL];\n\n if (typeof maybeIterator === 'function') {\n return maybeIterator;\n }\n\n return null;\n}\n\nvar ReactSharedInternals = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;\n\nfunction error(format) {\n {\n {\n for (var _len2 = arguments.length, args = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) {\n args[_key2 - 1] = arguments[_key2];\n }\n\n printWarning('error', format, args);\n }\n }\n}\n\nfunction printWarning(level, format, args) {\n // When changing this logic, you might want to also\n // update consoleWithStackDev.www.js as well.\n {\n var ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame;\n var stack = ReactDebugCurrentFrame.getStackAddendum();\n\n if (stack !== '') {\n format += '%s';\n args = args.concat([stack]);\n } // eslint-disable-next-line react-internal/safe-string-coercion\n\n\n var argsWithFormat = args.map(function (item) {\n return String(item);\n }); // Careful: RN currently depends on this prefix\n\n argsWithFormat.unshift('Warning: ' + format); // We intentionally don't use spread (or .apply) directly because it\n // breaks IE9: https://github.com/facebook/react/issues/13610\n // eslint-disable-next-line react-internal/no-production-logging\n\n Function.prototype.apply.call(console[level], console, argsWithFormat);\n }\n}\n\n// -----------------------------------------------------------------------------\n\nvar enableScopeAPI = false; // Experimental Create Event Handle API.\nvar enableCacheElement = false;\nvar enableTransitionTracing = false; // No known bugs, but needs performance testing\n\nvar enableLegacyHidden = false; // Enables unstable_avoidThisFallback feature in Fiber\n// stuff. Intended to enable React core members to more easily debug scheduling\n// issues in DEV builds.\n\nvar enableDebugTracing = false; // Track which Fiber(s) schedule render work.\n\nvar REACT_MODULE_REFERENCE;\n\n{\n REACT_MODULE_REFERENCE = Symbol.for('react.module.reference');\n}\n\nfunction isValidElementType(type) {\n if (typeof type === 'string' || typeof type === 'function') {\n return true;\n } // Note: typeof might be other than 'symbol' or 'number' (e.g. if it's a polyfill).\n\n\n if (type === REACT_FRAGMENT_TYPE || type === REACT_PROFILER_TYPE || enableDebugTracing || type === REACT_STRICT_MODE_TYPE || type === REACT_SUSPENSE_TYPE || type === REACT_SUSPENSE_LIST_TYPE || enableLegacyHidden || type === REACT_OFFSCREEN_TYPE || enableScopeAPI || enableCacheElement || enableTransitionTracing ) {\n return true;\n }\n\n if (typeof type === 'object' && type !== null) {\n if (type.$$typeof === REACT_LAZY_TYPE || type.$$typeof === REACT_MEMO_TYPE || type.$$typeof === REACT_PROVIDER_TYPE || type.$$typeof === REACT_CONTEXT_TYPE || type.$$typeof === REACT_FORWARD_REF_TYPE || // This needs to include all possible module reference object\n // types supported by any Flight configuration anywhere since\n // we don't know which Flight build this will end up being used\n // with.\n type.$$typeof === REACT_MODULE_REFERENCE || type.getModuleId !== undefined) {\n return true;\n }\n }\n\n return false;\n}\n\nfunction getWrappedName(outerType, innerType, wrapperNamLine truncated
|
||||
"mappings": "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 truncated
|
||||
"names": ["ReactDebugCurrentFrame"]
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
Generated
+1180
-18
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"
|
||||
|
||||
+31
-71
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 || ''
|
||||
}
|
||||
@@ -1,30 +1,25 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { FaTimes } from 'react-icons/fa'
|
||||
import { useAdminConfig } from '../hooks/useAdminConfig'
|
||||
import { useEmployees } from '../hooks/useEmployees'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, Query, isDemoMode } from '../lib/appwrite'
|
||||
import {
|
||||
SERVICE_TYPES,
|
||||
todayIso,
|
||||
isoToGermanDate,
|
||||
timeToWoms,
|
||||
buildRequesterSuggestions,
|
||||
rankSuggestions,
|
||||
} from '../lib/ticketForm'
|
||||
import { AutocompleteInput, DateTimeInput } from './ui'
|
||||
|
||||
// Fallback-Werte falls Config nicht geladen werden kann
|
||||
const DEFAULT_TICKET_TYPES = [
|
||||
'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request',
|
||||
'Maintenance', 'Project', 'Controlling', 'Development', 'Documentation',
|
||||
'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
|
||||
'Rollout', 'Emergency Call', 'Other Services'
|
||||
// Neue Tickets gibt es aktuell nur fuer diese Arten; alte Tickets behalten ihre Art.
|
||||
// Migration = ein Kunde, den wir von einem anderen Anbieter uebernehmen.
|
||||
const TICKET_TYPES = [
|
||||
{ value: 'Webpage', label: 'Webpage' },
|
||||
{ value: 'Migration', label: 'Migration (Übernahme von anderem Anbieter)' },
|
||||
]
|
||||
|
||||
const DEFAULT_SYSTEMS = [
|
||||
'Account View', 'Client', 'Cofano', 'Credentials', 'Diamant', 'Docuware',
|
||||
'EDI', 'eMail', 'Employee', 'Invoice', 'LBase', 'Medical Office', 'Network',
|
||||
'O365', 'PDF Viewer', 'Printer', 'Reports', 'Server', 'Time Tracking',
|
||||
'TK', 'TOS', 'Vivendi NG', 'VGM', '(W)LAN', '(W)WAN', 'WOMS', 'n/a'
|
||||
]
|
||||
|
||||
const DEFAULT_RESPONSE_LEVELS = [
|
||||
'USER', 'KEY USER', 'Helpdesk', 'Support', 'Admin', 'FS/FE', '24/7',
|
||||
'TECH MGMT', 'Backoffice', 'BUSI MGMT', 'n/a'
|
||||
]
|
||||
|
||||
const DEFAULT_SERVICE_TYPES = ['Remote', 'On Site', 'Off Site']
|
||||
|
||||
const DEFAULT_PRIORITIES = [
|
||||
{ value: 0, label: 'None' },
|
||||
{ value: 1, label: 'Low' },
|
||||
@@ -33,58 +28,59 @@ const DEFAULT_PRIORITIES = [
|
||||
{ value: 4, label: 'Critical' }
|
||||
]
|
||||
|
||||
const today = new Date().toLocaleDateString('de-DE')
|
||||
// Datum als "yyyy-mm-dd", Uhrzeit als "hh:mm" (Werte der Kalender-Felder)
|
||||
const emptyForm = () => ({
|
||||
customerId: '',
|
||||
type: TICKET_TYPES[0].value,
|
||||
serviceType: SERVICE_TYPES[0].value,
|
||||
priority: 1,
|
||||
topic: '',
|
||||
requestedBy: '',
|
||||
requestedFor: '',
|
||||
startDate: todayIso(),
|
||||
startTime: '',
|
||||
deadline: todayIso(),
|
||||
endTime: '',
|
||||
estimate: '30',
|
||||
details: ''
|
||||
})
|
||||
|
||||
export default function CreateTicketModal({ isOpen, onClose, onCreate, customers = [] }) {
|
||||
const { config } = useAdminConfig()
|
||||
const { employees } = useEmployees()
|
||||
|
||||
// Verwende Config-Werte oder Fallbacks
|
||||
const TICKET_TYPES = config?.ticketTypes || DEFAULT_TICKET_TYPES
|
||||
const SYSTEMS = config?.systems || DEFAULT_SYSTEMS
|
||||
const RESPONSE_LEVELS = config?.responseLevels || DEFAULT_RESPONSE_LEVELS
|
||||
const SERVICE_TYPES = config?.serviceTypes || DEFAULT_SERVICE_TYPES
|
||||
|
||||
const PRIORITIES = config?.priorities || DEFAULT_PRIORITIES
|
||||
const [formData, setFormData] = useState({
|
||||
customerId: '',
|
||||
type: '',
|
||||
systemType: '',
|
||||
responseLevel: '',
|
||||
serviceType: '',
|
||||
priority: 1,
|
||||
topic: '',
|
||||
requestedBy: '',
|
||||
requestedFor: '',
|
||||
assignedTo: '', // Zugewiesener Mitarbeiter (User ID)
|
||||
status: 'Open', // Status wird automatisch gesetzt basierend auf assignedTo
|
||||
startDate: today,
|
||||
startTime: '',
|
||||
deadline: today,
|
||||
endTime: '',
|
||||
estimate: '30',
|
||||
mailCopyTo: '',
|
||||
sendNotification: false,
|
||||
details: ''
|
||||
})
|
||||
const [formData, setFormData] = useState(emptyForm)
|
||||
const [recentTickets, setRecentTickets] = useState([])
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Setze Default-Werte wenn Config geladen ist oder Modal geöffnet wird
|
||||
// Reset error when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && (TICKET_TYPES.length > 0 || SERVICE_TYPES.length > 0)) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
type: prev.type || TICKET_TYPES[0] || 'Supportrequest',
|
||||
serviceType: prev.serviceType || SERVICE_TYPES[0] || 'Remote',
|
||||
priority: prev.priority || (PRIORITIES.find(p => p.value === 1)?.value || 1)
|
||||
}))
|
||||
if (isOpen) setError('')
|
||||
}, [isOpen])
|
||||
|
||||
// Namen aus den letzten Tickets fuer die Autovervollstaendigung
|
||||
useEffect(() => {
|
||||
if (!isOpen || isDemoMode) return
|
||||
let cancelled = false
|
||||
databases
|
||||
.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.orderDesc('$createdAt'), Query.limit(200)])
|
||||
.then((response) => {
|
||||
if (!cancelled) setRecentTickets(response.documents || [])
|
||||
})
|
||||
.catch((err) => console.warn('Vorschläge aus früheren Tickets nicht geladen:', err))
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// Reset error when modal opens
|
||||
if (isOpen) {
|
||||
setError('')
|
||||
}
|
||||
}, [isOpen, TICKET_TYPES, SERVICE_TYPES, PRIORITIES])
|
||||
}, [isOpen])
|
||||
|
||||
const suggestions = useMemo(
|
||||
() => buildRequesterSuggestions({ customers, employees, tickets: recentTickets }),
|
||||
[customers, employees, recentTickets]
|
||||
)
|
||||
const getSuggestions = (text) => rankSuggestions(suggestions, text, { customerId: formData.customerId })
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
@@ -92,34 +88,35 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
||||
if (error) setError('')
|
||||
}
|
||||
|
||||
// Das Ende darf nicht vor dem Start liegen, deshalb rueckt es mit
|
||||
const handleStartDateChange = (value) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
startDate: value,
|
||||
deadline: value && prev.deadline && prev.deadline < value ? value : prev.deadline
|
||||
}))
|
||||
if (error) setError('')
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
|
||||
const customer = customers.find(c => c.$id === formData.customerId)
|
||||
try {
|
||||
const result = await onCreate(formData)
|
||||
const result = await onCreate({
|
||||
...formData,
|
||||
customerName: customer?.name || '',
|
||||
customerLocation: customer?.location || '',
|
||||
startDate: isoToGermanDate(formData.startDate),
|
||||
startTime: timeToWoms(formData.startTime),
|
||||
deadline: isoToGermanDate(formData.deadline),
|
||||
endTime: timeToWoms(formData.endTime)
|
||||
})
|
||||
if (result.success) {
|
||||
onClose()
|
||||
setFormData({
|
||||
customerId: '',
|
||||
type: TICKET_TYPES[0] || 'Supportrequest',
|
||||
systemType: '',
|
||||
responseLevel: '',
|
||||
serviceType: SERVICE_TYPES[0] || 'Remote',
|
||||
priority: PRIORITIES.find(p => p.value === 1)?.value || 1,
|
||||
topic: '',
|
||||
requestedBy: '',
|
||||
requestedFor: '',
|
||||
startDate: today,
|
||||
startTime: '',
|
||||
deadline: today,
|
||||
endTime: '',
|
||||
estimate: '30',
|
||||
mailCopyTo: '',
|
||||
sendNotification: false,
|
||||
details: ''
|
||||
})
|
||||
setFormData(emptyForm())
|
||||
} else {
|
||||
setError(result.error || 'Fehler beim Erstellen des Tickets')
|
||||
}
|
||||
@@ -140,13 +137,13 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
||||
</span>
|
||||
<div className="overlay-content">
|
||||
<h2 className="mb-2">Create New Ticket</h2>
|
||||
|
||||
|
||||
{error && (
|
||||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="row">
|
||||
<div className="col col-6">
|
||||
@@ -173,36 +170,7 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
||||
onChange={(e) => handleChange('type', e.target.value)}
|
||||
>
|
||||
{TICKET_TYPES.map(type => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Affected System</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={formData.systemType}
|
||||
onChange={(e) => handleChange('systemType', e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Affected System</option>
|
||||
{SYSTEMS.map(sys => (
|
||||
<option key={sys} value={sys}>{sys}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Response Level</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={formData.responseLevel}
|
||||
onChange={(e) => handleChange('responseLevel', e.target.value)}
|
||||
>
|
||||
<option value="">Response Level</option>
|
||||
{RESPONSE_LEVELS.map(level => (
|
||||
<option key={level} value={level}>{level}</option>
|
||||
<option key={type.value} value={type.value}>{type.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -215,7 +183,7 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
||||
onChange={(e) => handleChange('serviceType', e.target.value)}
|
||||
>
|
||||
{SERVICE_TYPES.map(type => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
<option key={type.value} value={type.value}>{type.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -235,30 +203,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>
|
||||
|
||||
@@ -276,70 +227,51 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Requested by</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
<label className="form-label" htmlFor="ticket-requested-by">Requested by</label>
|
||||
<AutocompleteInput
|
||||
id="ticket-requested-by"
|
||||
placeholder="Name"
|
||||
value={formData.requestedBy}
|
||||
onChange={(e) => handleChange('requestedBy', e.target.value)}
|
||||
onChange={(value) => handleChange('requestedBy', value)}
|
||||
getSuggestions={getSuggestions}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Requested for</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
<label className="form-label" htmlFor="ticket-requested-for">Requested for</label>
|
||||
<AutocompleteInput
|
||||
id="ticket-requested-for"
|
||||
placeholder="Name"
|
||||
value={formData.requestedFor}
|
||||
onChange={(e) => handleChange('requestedFor', e.target.value)}
|
||||
onChange={(value) => handleChange('requestedFor', value)}
|
||||
getSuggestions={getSuggestions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Start Date & Time</label>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="dd.mm.yyyy"
|
||||
value={formData.startDate}
|
||||
onChange={(e) => handleChange('startDate', e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="hhmm"
|
||||
value={formData.startTime}
|
||||
onChange={(e) => handleChange('startTime', e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
<label className="form-label" htmlFor="ticket-start-date">Start Date & Time</label>
|
||||
<DateTimeInput
|
||||
id="ticket-start-date"
|
||||
timeLabel="Start time"
|
||||
date={formData.startDate}
|
||||
time={formData.startTime}
|
||||
onDateChange={handleStartDateChange}
|
||||
onTimeChange={(value) => handleChange('startTime', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">End Date & Time (Deadline)</label>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="dd.mm.yyyy"
|
||||
value={formData.deadline}
|
||||
onChange={(e) => handleChange('deadline', e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="hhmm"
|
||||
value={formData.endTime}
|
||||
onChange={(e) => handleChange('endTime', e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
<label className="form-label" htmlFor="ticket-end-date">End Date & Time (Deadline)</label>
|
||||
<DateTimeInput
|
||||
id="ticket-end-date"
|
||||
timeLabel="End time"
|
||||
min={formData.startDate}
|
||||
date={formData.deadline}
|
||||
time={formData.endTime}
|
||||
onDateChange={(value) => handleChange('deadline', value)}
|
||||
onTimeChange={(value) => handleChange('endTime', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
@@ -352,28 +284,6 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
||||
onChange={(e) => handleChange('estimate', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Notification Recipient(s)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="name@domain.xx"
|
||||
value={formData.mailCopyTo}
|
||||
onChange={(e) => handleChange('mailCopyTo', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.sendNotification}
|
||||
onChange={(e) => handleChange('sendNotification', e.target.checked)}
|
||||
/>
|
||||
Send Notification?
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -389,8 +299,8 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-2">
|
||||
<button
|
||||
type="submit"
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-dark"
|
||||
disabled={loading}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { FaTimes } from 'react-icons/fa'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
const SERVICE_TYPES = ['Remote', 'On Site', 'Off Site', 'COMMENT']
|
||||
import { SERVICE_TYPES, todayIso, isoToGermanDate, timeToWoms, minutesBetween } from '../lib/ticketForm'
|
||||
import { DateTimeInput } from './ui'
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
'Open',
|
||||
@@ -17,39 +17,36 @@ const STATUS_OPTIONS = [
|
||||
'In Test'
|
||||
]
|
||||
|
||||
const RESPONSE_LEVELS = [
|
||||
'KEY USER',
|
||||
'1st Level',
|
||||
'2nd Level',
|
||||
'3rd Level',
|
||||
'FS/FE',
|
||||
'24/7',
|
||||
'TECH MGMT',
|
||||
'Backoffice',
|
||||
'BUSI MGMT',
|
||||
'n/a'
|
||||
]
|
||||
// Akquise-Tickets: Ergebnis des Kundenkontakts (statt technischer Status)
|
||||
const ACQUISITION_STATUS = ['In Kontakt', 'Zugesagt', 'Abgesagt']
|
||||
|
||||
// Datum als "yyyy-mm-dd", Uhrzeit als "hh:mm" (Werte der Kalender-Felder).
|
||||
// Die Service-Art kommt vom Ticket; das alte "Off Site" zaehlt als Fernwartung.
|
||||
const initialForm = (workorder, newStatus) => ({
|
||||
serviceType: SERVICE_TYPES.some(t => t.value === workorder?.serviceType)
|
||||
? workorder.serviceType
|
||||
: SERVICE_TYPES[0].value,
|
||||
newStatus,
|
||||
totalTime: 0,
|
||||
startDate: todayIso(),
|
||||
startTime: '',
|
||||
endDate: todayIso(),
|
||||
endTime: '',
|
||||
details: '',
|
||||
isComment: false,
|
||||
countsToPlan: false
|
||||
})
|
||||
|
||||
export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate }) {
|
||||
const { user } = useAuth()
|
||||
const today = new Date().toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
serviceType: 'Remote',
|
||||
newStatus: workorder?.status || 'Open',
|
||||
newResponseLevel: workorder?.responseLevel || '',
|
||||
totalTime: 0,
|
||||
startDate: today,
|
||||
startTime: '',
|
||||
endDate: today,
|
||||
endTime: '',
|
||||
details: '',
|
||||
isComment: false
|
||||
})
|
||||
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')
|
||||
|
||||
const [formData, setFormData] = useState(() => initialForm(workorder, defaultStatus))
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -58,63 +55,44 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
||||
// Reset form wenn Modal geöffnet wird
|
||||
useEffect(() => {
|
||||
if (isOpen && workorder) {
|
||||
setFormData({
|
||||
serviceType: 'Remote',
|
||||
newStatus: workorder.status || 'Open',
|
||||
newResponseLevel: workorder.responseLevel || '',
|
||||
totalTime: 0,
|
||||
startDate: today,
|
||||
startTime: '',
|
||||
endDate: today,
|
||||
endTime: '',
|
||||
details: '',
|
||||
isComment: false
|
||||
})
|
||||
setFormData(initialForm(workorder, defaultStatus))
|
||||
setError('')
|
||||
setAutoCalculate(true)
|
||||
}
|
||||
}, [isOpen, workorder, today])
|
||||
}, [isOpen, workorder, defaultStatus])
|
||||
|
||||
// Automatische Zeitberechnung
|
||||
useEffect(() => {
|
||||
if (autoCalculate && formData.startTime && formData.endTime && !formData.isComment) {
|
||||
try {
|
||||
const startHour = parseInt(formData.startTime.substring(0, 2))
|
||||
const startMin = parseInt(formData.startTime.substring(2, 4))
|
||||
const endHour = parseInt(formData.endTime.substring(0, 2))
|
||||
const endMin = parseInt(formData.endTime.substring(2, 4))
|
||||
|
||||
if (!isNaN(startHour) && !isNaN(startMin) && !isNaN(endHour) && !isNaN(endMin)) {
|
||||
const startTotal = startHour * 60 + startMin
|
||||
const endTotal = endHour * 60 + endMin
|
||||
|
||||
let diff = endTotal - startTotal
|
||||
if (diff < 0) {
|
||||
diff += 24 * 60 // Overnight
|
||||
}
|
||||
|
||||
setFormData(prev => ({ ...prev, totalTime: diff }))
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignoriere Fehler
|
||||
}
|
||||
if (!autoCalculate || formData.isComment) return
|
||||
const minutes = minutesBetween(formData.startTime, formData.endTime)
|
||||
if (minutes !== null) {
|
||||
setFormData(prev => ({ ...prev, totalTime: minutes }))
|
||||
}
|
||||
}, [formData.startTime, formData.endTime, formData.isComment, autoCalculate])
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
|
||||
// Wenn totalTime manuell geändert wird, deaktiviere Auto-Berechnung
|
||||
if (field === 'totalTime') {
|
||||
setAutoCalculate(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Das Ende darf nicht vor dem Start liegen, deshalb rueckt es mit
|
||||
const handleStartDateChange = (value) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
startDate: value,
|
||||
endDate: value && prev.endDate && prev.endDate < value ? value : prev.endDate
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
|
||||
try {
|
||||
if (!formData.details.trim()) {
|
||||
setError('Bitte Details eingeben')
|
||||
@@ -128,20 +106,19 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
||||
serviceType: formData.serviceType,
|
||||
oldStatus: workorder.status,
|
||||
newStatus: formData.newStatus,
|
||||
oldResponseLevel: workorder.responseLevel || '',
|
||||
newResponseLevel: formData.newResponseLevel,
|
||||
totalTime: formData.isComment ? 0 : parseInt(formData.totalTime) || 0,
|
||||
startDate: formData.startDate,
|
||||
startTime: formData.startTime,
|
||||
endDate: formData.endDate,
|
||||
endTime: formData.endTime,
|
||||
startDate: isoToGermanDate(formData.startDate),
|
||||
startTime: timeToWoms(formData.startTime),
|
||||
endDate: isoToGermanDate(formData.endDate),
|
||||
endTime: timeToWoms(formData.endTime),
|
||||
details: formData.details,
|
||||
isComment: formData.isComment,
|
||||
countsToPlan: !formData.isComment && formData.countsToPlan,
|
||||
employeeShort: user?.prefs?.shortCode || '' // Aus User-Preferences
|
||||
}
|
||||
|
||||
const result = await onCreate(worksheetData, user)
|
||||
|
||||
|
||||
if (result.success) {
|
||||
onClose()
|
||||
} else {
|
||||
@@ -164,13 +141,13 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
||||
</span>
|
||||
<div className="overlay-content">
|
||||
<h2 className="mb-2">Create New Worksheet - WOID {workorder.woid}</h2>
|
||||
|
||||
|
||||
{error && (
|
||||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="row">
|
||||
<div className="col col-6">
|
||||
@@ -183,37 +160,28 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
||||
required
|
||||
>
|
||||
{SERVICE_TYPES.map(type => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
<option key={type.value} value={type.value}>{type.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">New Response Level</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={formData.newResponseLevel}
|
||||
onChange={(e) => handleChange('newResponseLevel', e.target.value)}
|
||||
>
|
||||
<option value="">Select Response Level</option>
|
||||
{RESPONSE_LEVELS.map(level => (
|
||||
<option key={level} value={level}>{level}</option>
|
||||
))}
|
||||
</select>
|
||||
{isAcquisition && (
|
||||
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||
"Zugesagt" macht den Lead automatisch zum festen Kunden.
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
@@ -226,75 +194,67 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
||||
Nur Kommentar (keine Arbeitszeit)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }} title="Zeit wird vom Stundenguthaben des Wartungs-Abos abgezogen und im Kundenportal angezeigt">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.countsToPlan && !formData.isComment}
|
||||
disabled={formData.isComment}
|
||||
onChange={(e) => handleChange('countsToPlan', e.target.checked)}
|
||||
/>
|
||||
Vom Abo-Stundenkonto abziehen
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col col-6">
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="worksheet-start-date">Start Date & Time</label>
|
||||
<DateTimeInput
|
||||
id="worksheet-start-date"
|
||||
timeLabel="Start time"
|
||||
required
|
||||
date={formData.startDate}
|
||||
time={formData.startTime}
|
||||
onDateChange={handleStartDateChange}
|
||||
onTimeChange={(value) => handleChange('startTime', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="worksheet-end-date">End Date & Time</label>
|
||||
<DateTimeInput
|
||||
id="worksheet-end-date"
|
||||
timeLabel="End time"
|
||||
required
|
||||
min={formData.startDate}
|
||||
date={formData.endDate}
|
||||
time={formData.endTime}
|
||||
onDateChange={(value) => handleChange('endDate', value)}
|
||||
onTimeChange={(value) => handleChange('endTime', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Total Time (Minuten)</label>
|
||||
{/* Ganze Minuten: mit step 15 blockierte der Browser berechnete Zeiten wie 70 */}
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
min="0"
|
||||
step="15"
|
||||
step="1"
|
||||
value={formData.totalTime}
|
||||
onChange={(e) => handleChange('totalTime', e.target.value)}
|
||||
disabled={formData.isComment}
|
||||
placeholder="0"
|
||||
/>
|
||||
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||
{autoCalculate && formData.startTime && formData.endTime
|
||||
? '✓ Automatisch berechnet'
|
||||
{autoCalculate && formData.startTime && formData.endTime
|
||||
? '✓ Automatisch berechnet'
|
||||
: 'Manuell eingeben'}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Start Date</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="dd.mm.yyyy"
|
||||
value={formData.startDate}
|
||||
onChange={(e) => handleChange('startDate', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">End Date</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="dd.mm.yyyy"
|
||||
value={formData.endDate}
|
||||
onChange={(e) => handleChange('endDate', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Start Time</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="hhmm"
|
||||
value={formData.startTime}
|
||||
onChange={(e) => handleChange('startTime', e.target.value)}
|
||||
maxLength="4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">End Time</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="hhmm"
|
||||
value={formData.endTime}
|
||||
onChange={(e) => handleChange('endTime', e.target.value)}
|
||||
maxLength="4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -311,8 +271,8 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-2">
|
||||
<button
|
||||
type="submit"
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-dark"
|
||||
disabled={loading}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' }}> � {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: '�', 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>
|
||||
)
|
||||
}
|
||||
+94
-140
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -42,7 +42,6 @@ export default function QuickOverviewModal({ isOpen, onClose, workorders = [] })
|
||||
<th>WOID</th>
|
||||
<th>Created</th>
|
||||
<th>Type</th>
|
||||
<th>System</th>
|
||||
<th>CID & CLO</th>
|
||||
<th>Requested by</th>
|
||||
<th>Topic</th>
|
||||
@@ -61,7 +60,6 @@ export default function QuickOverviewModal({ isOpen, onClose, workorders = [] })
|
||||
{format(new Date(wo.$createdAt || wo.createdAt), 'dd.MM.yyyy, HH:mm')}h
|
||||
</td>
|
||||
<td className="bg-light-grey text-small">{wo.type}</td>
|
||||
<td className="bg-light-grey text-small">{wo.systemType || 'n/a'}</td>
|
||||
<td className="text-small">{wo.customerCode} {wo.customerLocation}</td>
|
||||
<td className="text-small">{wo.requestedBy}</td>
|
||||
<td className="text-small">{wo.topic}</td>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
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 CreateWorksheetModal from './CreateWorksheetModal'
|
||||
import StatusHistoryModal from './StatusHistoryModal'
|
||||
import WorksheetList from './WorksheetList'
|
||||
import WorksheetTimesModal from './WorksheetTimesModal'
|
||||
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 { useAuth } from '../context/AuthContext'
|
||||
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
|
||||
import { serviceTypeLabel, canEditWorksheetTimes } from '../lib/ticketForm'
|
||||
|
||||
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 { user, employee } = useAuth()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
|
||||
const [editTimesWorksheet, setEditTimesWorksheet] = useState(null)
|
||||
const [showHistoryModal, setShowHistoryModal] = useState(false)
|
||||
const [showProjectsModal, setShowProjectsModal] = useState(false)
|
||||
const [projectsRefreshKey, setProjectsRefreshKey] = useState(0)
|
||||
|
||||
const { worksheets, loading: worksheetsLoading, createWorksheet, updateWorksheetTimes, 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 })
|
||||
}
|
||||
// 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">· {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.serviceType && <Badge tone="info">{serviceTypeLabel(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>
|
||||
|
||||
<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}
|
||||
canEditTimes={(ws) => canEditWorksheetTimes(ws, user, employee)}
|
||||
onEditTimes={setEditTimesWorksheet}
|
||||
/>
|
||||
</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} />
|
||||
{editTimesWorksheet && (
|
||||
<WorksheetTimesModal
|
||||
worksheet={editTimesWorksheet}
|
||||
onClose={() => setEditTimesWorksheet(null)}
|
||||
onSave={(ws, times) => updateWorksheetTimes(ws, times, user, employee)}
|
||||
/>
|
||||
)}
|
||||
<StatusHistoryModal isOpen={showHistoryModal} onClose={() => setShowHistoryModal(false)} worksheets={worksheets} ticket={ticket} />
|
||||
<TicketProjectsModal isOpen={showProjectsModal} onClose={() => setShowProjectsModal(false)} ticket={ticket} onUpdated={() => setProjectsRefreshKey((k) => k + 1)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+129
-176
@@ -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,44 @@ 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)
|
||||
// Wartungs-Abo: Arbeitszeit aufs Stundenkonto des Kunden buchen (Kundenportal zeigt den Stand)
|
||||
const handleTogglePlan = async (ws) => updateWorksheet(ws.$id, { countsToPlan: !ws.countsToPlan })
|
||||
|
||||
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 +91,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 +136,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 +159,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 +239,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} onTogglePlan={handleTogglePlan} />
|
||||
</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>
|
||||
|
||||
@@ -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�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,18 @@
|
||||
import { useState } from 'react'
|
||||
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp } from 'react-icons/fa'
|
||||
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp, FaPen } from 'react-icons/fa'
|
||||
import { serviceTypeLabel, needsTimeEntry } from '../lib/ticketForm'
|
||||
|
||||
export default function WorksheetList({ worksheets, totalTime, loading }) {
|
||||
const headerButtonStyle = {
|
||||
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',
|
||||
marginRight: '8px',
|
||||
}
|
||||
|
||||
export default function WorksheetList({ worksheets, totalTime, loading, onEditTime, onTogglePlan, canEditTimes, onEditTimes }) {
|
||||
const [expandedWorksheets, setExpandedWorksheets] = useState({})
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -49,13 +60,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 +82,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 +94,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 +116,27 @@ 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>
|
||||
)}
|
||||
{ws.countsToPlan && !ws.isComment && (
|
||||
<span className="badge ms-2" title="Wird vom Stundenguthaben des Wartungs-Abos abgezogen" style={{
|
||||
background: '#9d3dfe'
|
||||
}}>
|
||||
Abo-Stunden
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Collapsed: Mitarbeiter & Zeit im Header */}
|
||||
{!isExpanded && (
|
||||
@@ -122,13 +154,65 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
|
||||
<span className="badge" style={{
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
fontSize: '0.8rem'
|
||||
}}>{ws.serviceType}</span>
|
||||
}}>{serviceTypeLabel(ws.serviceType)}</span>
|
||||
</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={headerButtonStyle}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEditTime(ws)
|
||||
}}
|
||||
>
|
||||
<FaClock className="me-1" /> Zeit nachtragen
|
||||
</button>
|
||||
)}
|
||||
{onEditTimes && canEditTimes?.(ws) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
title="Beginn, Ende und Arbeitszeit ändern"
|
||||
style={headerButtonStyle}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEditTimes(ws)
|
||||
}}
|
||||
>
|
||||
{needsTimeEntry(ws)
|
||||
? <><FaClock className="me-1" /> Zeit nachtragen</>
|
||||
: <><FaPen className="me-1" /> Zeiten bearbeiten</>}
|
||||
</button>
|
||||
)}
|
||||
{onTogglePlan && !ws.isComment && Number(ws.totalTime) > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
title={ws.countsToPlan ? 'Nicht mehr vom Abo-Stundenkonto abziehen' : 'Vom Abo-Stundenkonto des Kunden abziehen'}
|
||||
style={{
|
||||
background: ws.countsToPlan ? '#9d3dfe' : 'rgba(255,255,255,0.15)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(255,255,255,0.4)',
|
||||
borderRadius: '6px',
|
||||
padding: '2px 10px',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onTogglePlan(ws)
|
||||
}}
|
||||
>
|
||||
{ws.countsToPlan ? '✓ Abo' : '+ Abo'}
|
||||
</button>
|
||||
)}
|
||||
<small style={{ opacity: 0.9 }}>
|
||||
{formatDateTime(ws.startDate, ws.startTime) || formatDateTime(ws.endDate, ws.endTime)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body - Nur wenn expanded */}
|
||||
@@ -169,7 +253,7 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
|
||||
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
||||
color: 'white',
|
||||
fontSize: '0.85rem'
|
||||
}}>{ws.serviceType}</span>
|
||||
}}>{serviceTypeLabel(ws.serviceType)}</span>
|
||||
</div>
|
||||
|
||||
{/* Status-Änderung */}
|
||||
@@ -185,16 +269,6 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Response Level-Änderung */}
|
||||
{ws.oldResponseLevel && ws.newResponseLevel && ws.oldResponseLevel !== ws.newResponseLevel && (
|
||||
<div className="mb-3">
|
||||
<span className="text-muted">Response Level:</span>{' '}
|
||||
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{ws.oldResponseLevel}</span>
|
||||
<span className="mx-2">→</span>
|
||||
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{ws.newResponseLevel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details */}
|
||||
<div className="mt-3 p-3 rounded-3" style={{
|
||||
background: 'rgba(16, 185, 129, 0.05)',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FaClock, FaUsers, FaChartLine } from 'react-icons/fa'
|
||||
import { serviceTypeLabel } from '../lib/ticketForm'
|
||||
|
||||
export default function WorksheetStats({ worksheets }) {
|
||||
if (!worksheets || worksheets.length === 0) {
|
||||
@@ -29,9 +30,9 @@ export default function WorksheetStats({ worksheets }) {
|
||||
}, {})
|
||||
|
||||
|
||||
// Service Type Verteilung
|
||||
// Service Type Verteilung (Remote und das alte Off Site zusammen als Fernwartung)
|
||||
const byServiceType = worksheets.reduce((acc, ws) => {
|
||||
const type = ws.serviceType || 'Unknown'
|
||||
const type = serviceTypeLabel(ws.serviceType) || 'Unknown'
|
||||
acc[type] = (acc[type] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState } from 'react'
|
||||
import { FaTimes } from 'react-icons/fa'
|
||||
import { isoToGermanDate, timeToWoms, germanDateToIso, womsTimeToInput, minutesBetween, isGitWorksheet, needsTimeEntry } from '../lib/ticketForm'
|
||||
import { DateTimeInput } from './ui'
|
||||
|
||||
// Gespeichert ist dd.mm.yyyy / hhmm, die Kalender-Felder brauchen yyyy-mm-dd / hh:mm
|
||||
const formFromWorksheet = (worksheet) => ({
|
||||
startDate: germanDateToIso(worksheet.startDate || worksheet.endDate),
|
||||
startTime: womsTimeToInput(worksheet.startTime),
|
||||
endDate: germanDateToIso(worksheet.endDate || worksheet.startDate),
|
||||
endTime: womsTimeToInput(worksheet.endTime)
|
||||
})
|
||||
|
||||
/**
|
||||
* Beginn, Ende und Arbeitszeit eines Arbeitsblatts nachtraeglich aendern.
|
||||
* Wer das darf, entscheidet canEditWorksheetTimes (lib/ticketForm).
|
||||
*/
|
||||
export default function WorksheetTimesModal({ worksheet, onClose, onSave }) {
|
||||
const isGit = isGitWorksheet(worksheet)
|
||||
const [formData, setFormData] = useState(() => formFromWorksheet(worksheet))
|
||||
// null = die Arbeitszeit folgt Beginn und Ende. Passte sie schon vorher nicht dazu,
|
||||
// wurde sie von Hand eingetragen (z.B. ohne Pause) und bleibt stehen.
|
||||
const [manualTotal, setManualTotal] = useState(() => {
|
||||
const stored = Number(worksheet.totalTime) || 0
|
||||
return !stored || stored === minutesBetween(formData.startTime, formData.endTime) ? null : stored
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const calculatedTotal = minutesBetween(formData.startTime, formData.endTime)
|
||||
const totalTime = manualTotal ?? calculatedTotal ?? 0
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
// Das Ende darf nicht vor dem Start liegen, deshalb rueckt es mit
|
||||
const handleStartDateChange = (value) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
startDate: value,
|
||||
endDate: value && prev.endDate && prev.endDate < value ? value : prev.endDate
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await onSave(worksheet, {
|
||||
startDate: isoToGermanDate(formData.startDate),
|
||||
startTime: timeToWoms(formData.startTime),
|
||||
endDate: isoToGermanDate(formData.endDate),
|
||||
endTime: timeToWoms(formData.endTime),
|
||||
totalTime: parseInt(totalTime, 10) || 0
|
||||
})
|
||||
if (result?.success) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
setError(result?.error || 'Speichern fehlgeschlagen')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Speichern fehlgeschlagen')
|
||||
}
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overlay">
|
||||
<span className="overlay-close" onClick={onClose}>
|
||||
<FaTimes />
|
||||
</span>
|
||||
<div className="overlay-content" style={{ maxWidth: 560 }}>
|
||||
{/* Kurzer Titel: auf dem Handy laeuft ein langer unter das Schliessen-X */}
|
||||
<h2 className="mb-2">{needsTimeEntry(worksheet) ? 'Zeit nachtragen' : 'Zeiten bearbeiten'}</h2>
|
||||
<p className="text-grey" style={{ fontSize: '14px', marginBottom: '16px' }}>
|
||||
WSID {worksheet.wsid}
|
||||
{isGit && <><br />Das Ende ist der Zeitpunkt des Git-Pushs. Trag 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" htmlFor="worksheet-times-start">Beginn</label>
|
||||
<DateTimeInput
|
||||
id="worksheet-times-start"
|
||||
timeLabel="Uhrzeit Beginn"
|
||||
required
|
||||
timeRequired={isGit}
|
||||
date={formData.startDate}
|
||||
time={formData.startTime}
|
||||
onDateChange={handleStartDateChange}
|
||||
onTimeChange={(value) => handleChange('startTime', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="worksheet-times-end">Ende</label>
|
||||
<DateTimeInput
|
||||
id="worksheet-times-end"
|
||||
timeLabel="Uhrzeit Ende"
|
||||
required
|
||||
min={formData.startDate}
|
||||
date={formData.endDate}
|
||||
time={formData.endTime}
|
||||
onDateChange={(value) => handleChange('endDate', value)}
|
||||
onTimeChange={(value) => handleChange('endTime', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="worksheet-times-total">Arbeitszeit (Minuten)</label>
|
||||
<input
|
||||
id="worksheet-times-total"
|
||||
type="number"
|
||||
className="form-control"
|
||||
min="0"
|
||||
step="1"
|
||||
value={totalTime}
|
||||
onChange={(e) => setManualTotal(e.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||
{manualTotal === null && calculatedTotal !== null
|
||||
? '✓ Automatisch berechnet'
|
||||
: 'Manuell eingeben'}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-2">
|
||||
<button type="submit" className="btn btn-dark" disabled={saving}>
|
||||
{saving ? 'Speichert...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useId, useState } from 'react'
|
||||
|
||||
/* Textfeld mit Vorschlagsliste. Freie Eingaben bleiben erlaubt;
|
||||
getSuggestions(text) liefert [{ value, hint }]. */
|
||||
export default function AutocompleteInput({ value, onChange, getSuggestions, id, placeholder, required }) {
|
||||
const listId = useId()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [active, setActive] = useState(-1)
|
||||
|
||||
const items = open ? getSuggestions(value) : []
|
||||
const showList = items.length > 0
|
||||
|
||||
const choose = (item) => {
|
||||
onChange(item.value)
|
||||
setOpen(false)
|
||||
setActive(-1)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setOpen(true)
|
||||
setActive((i) => Math.min(i + 1, items.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActive((i) => Math.max(i - 1, -1))
|
||||
} else if (e.key === 'Enter' && showList && items[active]) {
|
||||
e.preventDefault()
|
||||
choose(items[active])
|
||||
} else if (e.key === 'Escape' && showList) {
|
||||
e.preventDefault()
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="autocomplete">
|
||||
<input
|
||||
type="text"
|
||||
id={id}
|
||||
className="form-control"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={showList}
|
||||
aria-controls={listId}
|
||||
aria-activedescendant={showList && items[active] ? `${listId}-${active}` : undefined}
|
||||
autoComplete="off"
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value)
|
||||
setOpen(true)
|
||||
setActive(-1)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => {
|
||||
setOpen(false)
|
||||
setActive(-1)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{showList && (
|
||||
<ul className="autocomplete-list" id={listId} role="listbox">
|
||||
{items.map((item, index) => (
|
||||
<li
|
||||
key={item.value}
|
||||
id={`${listId}-${index}`}
|
||||
role="option"
|
||||
aria-selected={index === active}
|
||||
className={`autocomplete-item${index === active ? ' active' : ''}`}
|
||||
// mousedown statt click: sonst verliert das Feld vorher den Fokus und die Liste schliesst
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
choose(item)
|
||||
}}
|
||||
onMouseEnter={() => setActive(index)}
|
||||
>
|
||||
<span className="autocomplete-value">{item.value}</span>
|
||||
{item.hint && <span className="autocomplete-hint">{item.hint}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Klick irgendwo ins Datumsfeld oeffnet den Kalender, nicht nur das kleine Symbol
|
||||
const openCalendar = (e) => {
|
||||
try {
|
||||
e.currentTarget.showPicker?.()
|
||||
} catch {
|
||||
// aeltere Browser: Kalender bleibt ueber das Symbol erreichbar
|
||||
}
|
||||
}
|
||||
|
||||
/* Datum mit Kalender und Uhrzeit nebeneinander. Werte wie die Browser-Felder:
|
||||
Datum "yyyy-mm-dd", Uhrzeit "hh:mm" - umgerechnet wird erst beim Speichern. */
|
||||
export default function DateTimeInput({ id, date, time, onDateChange, onTimeChange, min, required, timeRequired, timeLabel }) {
|
||||
return (
|
||||
<div className="datetime-pair">
|
||||
<input
|
||||
id={id}
|
||||
type="date"
|
||||
className="form-control"
|
||||
min={min || undefined}
|
||||
required={required}
|
||||
value={date}
|
||||
onChange={(e) => onDateChange(e.target.value)}
|
||||
onClick={openCalendar}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
className="form-control"
|
||||
aria-label={timeLabel}
|
||||
required={timeRequired}
|
||||
value={time}
|
||||
onChange={(e) => onTimeChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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'
|
||||
export { default as AutocompleteInput } from './AutocompleteInput'
|
||||
export { default as DateTimeInput } from './DateTimeInput'
|
||||
+120
-50
@@ -1,26 +1,89 @@
|
||||
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 und liefert
|
||||
// den Mitarbeiter-Eintrag (Kürzel = Gitea-Name, siehe isWorksheetCreator) oder null
|
||||
async function ensureEmployeeExists(user) {
|
||||
if (!user || DEMO_MODE) return null
|
||||
|
||||
// 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(
|
||||
const employee = await databases.createDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.EMPLOYEES,
|
||||
ID.unique(),
|
||||
@@ -28,21 +91,24 @@ 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')
|
||||
return employee
|
||||
}
|
||||
return response.documents[0]
|
||||
} 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)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null)
|
||||
const [employee, setEmployee] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,7 +117,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 +124,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)
|
||||
setEmployee(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 +150,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)
|
||||
|
||||
setEmployee(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 +199,27 @@ 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)
|
||||
setEmployee(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,
|
||||
employee,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+71
-31
@@ -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,18 @@ 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,
|
||||
// Ohne Limit liefert Appwrite nur 25 Dokumente.
|
||||
[Query.orderAsc('name'), Query.limit(5000)]
|
||||
)
|
||||
setCustomers(response.documents || [])
|
||||
}
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Error fetching customers:', err)
|
||||
@@ -50,20 +68,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 +94,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 +141,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 +159,3 @@ export function useCustomers() {
|
||||
deleteCustomer
|
||||
}
|
||||
}
|
||||
|
||||
+71
-150
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
+116
-80
@@ -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,10 @@ export function useWorkorders(filters = {}) {
|
||||
responseLevel: data.responseLevel || '',
|
||||
serviceType: data.serviceType || 'Remote',
|
||||
customerId: data.customerId || '',
|
||||
assignedTo: data.assignedTo || '', // Zugewiesener Mitarbeiter
|
||||
customerName: data.customerName || '',
|
||||
customerLocation: data.customerLocation || '',
|
||||
assignedTo: automatic.assignedTo,
|
||||
assignedName: automatic.assignedName,
|
||||
requestedBy: data.requestedBy || '',
|
||||
requestedFor: data.requestedFor || '',
|
||||
startDate: data.startDate || '',
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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'
|
||||
import { canEditWorksheetTimes, isGitWorksheet } from '../lib/ticketForm'
|
||||
|
||||
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)
|
||||
@@ -340,6 +341,7 @@ export function useWorksheets(woid = null) {
|
||||
endTime: data.endTime || '',
|
||||
details: data.details.trim(),
|
||||
isComment: data.isComment || false,
|
||||
countsToPlan: !data.isComment && Boolean(data.countsToPlan),
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
@@ -387,6 +389,21 @@ export function useWorksheets(woid = null) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aendert nachtraeglich Beginn, Ende und Arbeitszeit - nur fuer den Ersteller
|
||||
* (employee = eigener Eintrag aus "employees", fuer Git-Pushes)
|
||||
*/
|
||||
const updateWorksheetTimes = async (worksheet, times, currentUser, employee) => {
|
||||
if (!canEditWorksheetTimes(worksheet, currentUser, employee)) {
|
||||
return { success: false, error: 'Nur wer das Arbeitsblatt erstellt hat, kann die Zeiten ändern.' }
|
||||
}
|
||||
const { startDate, startTime, endDate, endTime, totalTime } = times
|
||||
const data = { startDate, startTime, endDate, endTime, totalTime }
|
||||
// Mit nachgetragener Zeit zaehlt ein Git-Push als Arbeitszeit, nicht mehr als Kommentar
|
||||
if (isGitWorksheet(worksheet)) data.isComment = false
|
||||
return updateWorksheet(worksheet.$id, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht ein Worksheet (sollte normalerweise nicht erlaubt sein - Audit Trail!)
|
||||
*/
|
||||
@@ -467,6 +484,7 @@ export function useWorksheets(woid = null) {
|
||||
error,
|
||||
createWorksheet,
|
||||
updateWorksheet,
|
||||
updateWorksheetTimes,
|
||||
deleteWorksheet,
|
||||
refresh: fetchWorksheets,
|
||||
getTotalTime,
|
||||
|
||||
+54
-20
@@ -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 }
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/* Planboard-Logik: aus Tickets werden Termine.
|
||||
Termine sind kein eigener Datensatz - sie haengen an den Tickets selbst
|
||||
(startDate/startTime/endTime/assignedTo). Die Rechnungsanalyse im
|
||||
Kundenportal schreibt genau diese Felder, deshalb erscheint ein dort
|
||||
vereinbarter Termin hier ohne Umweg.
|
||||
WOMS speichert Datum als Text "dd.mm.yyyy" (auch einstellig: "2.5.2026")
|
||||
und Uhrzeit als "hhmm". Beides wird hier tolerant gelesen. */
|
||||
|
||||
export function parseGermanDate(value) {
|
||||
const m = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(String(value || '').trim())
|
||||
if (!m) return null
|
||||
const day = Number(m[1])
|
||||
const month = Number(m[2])
|
||||
const year = Number(m[3])
|
||||
const date = new Date(year, month - 1, day)
|
||||
// "31.02.2026" wuerde sonst still zum 3. Maerz werden
|
||||
if (date.getDate() !== day || date.getMonth() !== month - 1 || date.getFullYear() !== year) return null
|
||||
return date
|
||||
}
|
||||
|
||||
export function formatTime(value) {
|
||||
const m = /^(\d{1,2}):?(\d{2})$/.exec(String(value || '').trim())
|
||||
if (!m) return ''
|
||||
const hour = Number(m[1])
|
||||
const minute = Number(m[2])
|
||||
if (hour > 23 || minute > 59) return ''
|
||||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function dayKey(date) {
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
export function toAppointment(workorder, employeeByUserId = {}) {
|
||||
const date = parseGermanDate(workorder.startDate)
|
||||
if (!date) return null
|
||||
const employee = employeeByUserId[workorder.assignedTo]
|
||||
return {
|
||||
id: workorder.$id,
|
||||
woid: workorder.woid || '',
|
||||
date,
|
||||
key: dayKey(date),
|
||||
time: formatTime(workorder.startTime),
|
||||
endTime: formatTime(workorder.endTime),
|
||||
topic: workorder.topic || workorder.title || 'Ohne Betreff',
|
||||
type: workorder.type || '',
|
||||
status: workorder.status || '',
|
||||
customerName: workorder.customerName || '',
|
||||
employeeName: employee?.displayName || workorder.assignedName || '',
|
||||
employeeShort: employee?.shortcode || '',
|
||||
}
|
||||
}
|
||||
|
||||
/* Termine nach Tag ("yyyy-mm-dd"), innerhalb des Tages nach Uhrzeit.
|
||||
Eintraege ohne Uhrzeit landen am Ende des Tages. */
|
||||
export function groupAppointmentsByDay(workorders = [], employees = []) {
|
||||
const employeeByUserId = {}
|
||||
for (const e of employees) employeeByUserId[e.userId] = e
|
||||
|
||||
const map = {}
|
||||
for (const wo of workorders) {
|
||||
const appointment = toAppointment(wo, employeeByUserId)
|
||||
if (!appointment) continue
|
||||
;(map[appointment.key] = map[appointment.key] || []).push(appointment)
|
||||
}
|
||||
for (const key of Object.keys(map)) {
|
||||
map[key].sort((a, b) => (a.time || '99:99').localeCompare(b.time || '99:99'))
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export function countInRange(byDay, days = []) {
|
||||
return days.reduce((sum, d) => sum + (byDay[dayKey(d)]?.length || 0), 0)
|
||||
}
|
||||
@@ -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)}`
|
||||
)
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/* Hilfen fuer die Formulare "Neues Ticket" und "Arbeitsblatt" und die Anzeige.
|
||||
WOMS speichert Datum als Text "dd.mm.yyyy" und Uhrzeit als "hhmm"
|
||||
(siehe planboard.js). Die Kalender-Felder des Browsers liefern dagegen
|
||||
"yyyy-mm-dd" bzw. "hh:mm" - umgerechnet wird erst beim Speichern. */
|
||||
|
||||
import { parseGermanDate, formatTime, dayKey } from './planboard'
|
||||
|
||||
// Service Type: gespeichert werden die bisherigen Werte, angezeigt wird Deutsch
|
||||
export const SERVICE_TYPES = [
|
||||
{ value: 'Remote', label: 'Fernwartung (remote)' },
|
||||
{ value: 'On Site', label: 'Vor Ort beim Kunden' },
|
||||
]
|
||||
|
||||
// Kurzform fuer Badges und Listen. Das alte "Off Site" meinte ebenfalls Arbeit aus der Ferne.
|
||||
const SERVICE_TYPE_SHORT = {
|
||||
remote: 'Fernwartung',
|
||||
'off site': 'Fernwartung',
|
||||
'on site': 'Vor Ort',
|
||||
comment: 'Kommentar',
|
||||
}
|
||||
|
||||
export function serviceTypeLabel(value) {
|
||||
return SERVICE_TYPE_SHORT[String(value || '').trim().toLowerCase()] || value
|
||||
}
|
||||
|
||||
// Git-Pushes legt das Kundenportal als Arbeitsblatt an (employeeId "gitea"): Ende = Push,
|
||||
// der Beginn wird im Ticketsystem nachgetragen
|
||||
export function isGitWorksheet(worksheet) {
|
||||
return worksheet?.serviceType === 'GIT'
|
||||
}
|
||||
|
||||
export function needsTimeEntry(worksheet) {
|
||||
return isGitWorksheet(worksheet) && (!worksheet.startTime || !Number(worksheet.totalTime))
|
||||
}
|
||||
|
||||
const sameName = (a, b) => String(a || '').trim().toLowerCase() === String(b || '').trim().toLowerCase()
|
||||
|
||||
/* Wer hat das Arbeitsblatt erstellt? Normale Eintraege tragen die Appwrite-ID des
|
||||
Mitarbeiters. Bei Git-Pushes steht in employeeName der Gitea-Name des Pushers
|
||||
(Benutzername wie "knso" oder voller Name); er gehoert dem Mitarbeiter mit diesem
|
||||
Kuerzel oder Namen. employee ist der eigene Eintrag aus "employees". */
|
||||
export function isWorksheetCreator(worksheet, user, employee) {
|
||||
if (!user?.$id || !worksheet) return false
|
||||
if (!isGitWorksheet(worksheet)) return worksheet.employeeId === user.$id
|
||||
const pusher = String(worksheet.employeeName || '').trim()
|
||||
return Boolean(pusher) && [employee?.shortcode, employee?.displayName, user.name].some((name) => sameName(name, pusher))
|
||||
}
|
||||
|
||||
// Zeiten darf nur aendern, wer das Arbeitsblatt erstellt hat. Reine Kommentare haben
|
||||
// keine Arbeitszeit; Git-Pushes sind Kommentare, bis ihre Zeit nachgetragen ist.
|
||||
export function canEditWorksheetTimes(worksheet, user, employee) {
|
||||
return isWorksheetCreator(worksheet, user, employee) && (isGitWorksheet(worksheet) || !worksheet.isComment)
|
||||
}
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
|
||||
// Heutiges Datum in Ortszeit als "yyyy-mm-dd" (Wert fuer <input type="date">)
|
||||
export function todayIso(now = new Date()) {
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
}
|
||||
|
||||
// "2026-09-26" -> "26.09.2026"
|
||||
export function isoToGermanDate(iso) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''))
|
||||
return m ? `${m[3]}.${m[2]}.${m[1]}` : ''
|
||||
}
|
||||
|
||||
// "09:30" -> "0930"
|
||||
export function timeToWoms(value) {
|
||||
const m = /^(\d{1,2}):(\d{2})/.exec(String(value || ''))
|
||||
return m ? `${m[1].padStart(2, '0')}${m[2]}` : ''
|
||||
}
|
||||
|
||||
// Rueckweg fuers Bearbeiten: "26.09.2026" (auch "2.5.2026") -> "2026-09-26", sonst ""
|
||||
export function germanDateToIso(value) {
|
||||
const date = parseGermanDate(value)
|
||||
return date ? dayKey(date) : ''
|
||||
}
|
||||
|
||||
// "0930" -> "09:30", sonst ""
|
||||
export function womsTimeToInput(value) {
|
||||
return formatTime(value)
|
||||
}
|
||||
|
||||
// Minuten von "hh:mm" bis "hh:mm"; liegt das Ende davor, geht es ueber Mitternacht
|
||||
export function minutesBetween(start, end) {
|
||||
const a = /^(\d{1,2}):(\d{2})/.exec(String(start || ''))
|
||||
const b = /^(\d{1,2}):(\d{2})/.exec(String(end || ''))
|
||||
if (!a || !b) return null
|
||||
const diff = Number(b[1]) * 60 + Number(b[2]) - (Number(a[1]) * 60 + Number(a[2]))
|
||||
return diff < 0 ? diff + 24 * 60 : diff
|
||||
}
|
||||
|
||||
/* Vorschlaege fuer "Requested by" / "Requested for": Kunden, Mitarbeiter
|
||||
und Namen aus frueheren Tickets. Pro Name (ohne Gross-/Kleinschreibung)
|
||||
ein Eintrag; customerIds merkt sich, zu welchen Kunden er gehoert. */
|
||||
export function buildRequesterSuggestions({ customers = [], employees = [], tickets = [] }) {
|
||||
const byKey = new Map()
|
||||
const add = (value, { hint = '', search = '', customerId = '', used = false } = {}) => {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return
|
||||
const key = text.toLowerCase()
|
||||
let entry = byKey.get(key)
|
||||
if (!entry) {
|
||||
entry = { value: text, hint: '', search: key, customerIds: new Set(), uses: 0 }
|
||||
byKey.set(key, entry)
|
||||
}
|
||||
if (hint && !entry.hint) entry.hint = hint
|
||||
if (search) entry.search += ` ${search.toLowerCase()}`
|
||||
if (customerId) entry.customerIds.add(customerId)
|
||||
if (used) entry.uses += 1
|
||||
}
|
||||
|
||||
for (const customer of customers) {
|
||||
add(customer.name || customer.companyName, {
|
||||
hint: customer.email ? `Kunde · ${customer.email}` : 'Kunde',
|
||||
search: customer.email || '',
|
||||
customerId: customer.$id,
|
||||
})
|
||||
}
|
||||
for (const employee of employees) {
|
||||
add(employee.displayName, { hint: 'Mitarbeiter', search: employee.email || '' })
|
||||
}
|
||||
for (const ticket of tickets) {
|
||||
add(ticket.requestedBy, { customerId: ticket.customerId, used: true })
|
||||
add(ticket.requestedFor, { customerId: ticket.customerId, used: true })
|
||||
}
|
||||
for (const entry of byKey.values()) {
|
||||
if (!entry.hint) entry.hint = 'aus früheren Tickets'
|
||||
}
|
||||
return [...byKey.values()]
|
||||
}
|
||||
|
||||
/* Passende Vorschlaege zur Eingabe, beste zuerst. Namen des gewaehlten
|
||||
Kunden stehen oben; ohne Eingabe gibt es nur diese und schon benutzte
|
||||
Namen, damit die Liste nicht einfach alle Kunden aufzaehlt. */
|
||||
export function rankSuggestions(entries, query, { customerId = '', limit = 8 } = {}) {
|
||||
const q = String(query || '').trim().toLowerCase()
|
||||
const ranked = []
|
||||
for (const entry of entries) {
|
||||
const value = entry.value.toLowerCase()
|
||||
if (q && value === q) continue
|
||||
const forCustomer = Boolean(customerId) && entry.customerIds.has(customerId)
|
||||
let score
|
||||
if (!q) {
|
||||
if (!forCustomer && !entry.uses) continue
|
||||
score = 0
|
||||
} else if (value.startsWith(q)) {
|
||||
score = 3
|
||||
} else if (value.split(/[\s.@-]+/).some((word) => word.startsWith(q))) {
|
||||
score = 2
|
||||
} else if (entry.search.includes(q)) {
|
||||
score = 1
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
if (forCustomer) score += 2
|
||||
score += Math.min(entry.uses, 10) / 10
|
||||
ranked.push({ entry, score })
|
||||
}
|
||||
ranked.sort((a, b) => b.score - a.score || a.entry.value.localeCompare(b.entry.value, 'de'))
|
||||
return ranked.slice(0, limit).map(({ entry }) => entry)
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
+440
-637
File diff suppressed because it is too large.
Load diff
+20
-27
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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(' · ')}
|
||||
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}</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>
|
||||
)
|
||||
}
|
||||
@@ -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 }}>· {c.code}</span>}
|
||||
</div>
|
||||
<div className="muted">{[c.location, c.email].filter(Boolean).join(' · ') || '-'}</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>
|
||||
)
|
||||
}
|
||||
+81
-49
@@ -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>
|
||||
)
|
||||
|
||||
+14
-53
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: '—', 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+105
-32
@@ -1,45 +1,118 @@
|
||||
import { useState } from 'react'
|
||||
import { format, addDays, startOfWeek } from 'date-fns'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { format, addDays, startOfWeek, isSameDay } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6'
|
||||
import { PageHeader, Card, Button } from '../components/ui'
|
||||
import { useWorkorders } from '../hooks/useWorkorders'
|
||||
import { useEmployees } from '../hooks/useEmployees'
|
||||
import { groupAppointmentsByDay, countInRange, dayKey } from '../lib/planboard'
|
||||
|
||||
/* Termine haengen an den Tickets (startDate/startTime/endTime/assignedTo);
|
||||
die Aufbereitung steckt in src/lib/planboard.js. */
|
||||
|
||||
const TYPE_COLORS = {
|
||||
akquise: { bg: 'rgba(198,113,57,0.16)', fg: '#c67139' },
|
||||
webpage: { bg: 'rgba(122,138,94,0.18)', fg: '#7a8a5e' },
|
||||
project: { bg: 'rgba(122,138,94,0.18)', fg: '#7a8a5e' },
|
||||
}
|
||||
|
||||
export default function PlanboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const [currentWeek, setCurrentWeek] = useState(startOfWeek(new Date(), { weekStartsOn: 1 }))
|
||||
const { workorders, loading } = useWorkorders({ limit: 500 })
|
||||
const { employees } = useEmployees()
|
||||
|
||||
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeek, i))
|
||||
const navigateWeek = (dir) => setCurrentWeek((prev) => addDays(prev, dir * 7))
|
||||
const goToday = () => setCurrentWeek(startOfWeek(new Date(), { weekStartsOn: 1 }))
|
||||
|
||||
const navigateWeek = (direction) => {
|
||||
setCurrentWeek(prev => addDays(prev, direction * 7))
|
||||
}
|
||||
const byDay = useMemo(() => groupAppointmentsByDay(workorders || [], employees || []), [workorders, employees])
|
||||
const weekCount = countInRange(byDay, weekDays)
|
||||
|
||||
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={loading ? 'Wochenplanung – lädt…' : `Wochenplanung – ${weekCount} ${weekCount === 1 ? 'Termin' : 'Termine'} diese Woche`}
|
||||
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>
|
||||
<Button variant="ghost" onClick={goToday}>Heute</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) => {
|
||||
const entries = byDay[dayKey(day)] || []
|
||||
const today = isSameDay(day, new Date())
|
||||
return (
|
||||
<Card key={day.toISOString()} pad={false}>
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: today ? 'rgba(198,113,57,0.10)' : undefined,
|
||||
}}>
|
||||
<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: 8, minHeight: 120, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{entries.length === 0 && (
|
||||
<p className="faint" style={{ fontSize: 12, padding: '4px 4px 0' }}>Keine Termine</p>
|
||||
)}
|
||||
|
||||
{entries.map((entry) => {
|
||||
const color = TYPE_COLORS[String(entry.type).toLowerCase()]
|
||||
|| { bg: 'rgba(127,127,127,0.10)', fg: 'var(--border)' }
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
title={`Ticket #${entry.woid} öffnen`}
|
||||
onClick={() => navigate(`/tickets?woid=${encodeURIComponent(entry.woid)}`)}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
border: '1px solid var(--border)',
|
||||
borderLeft: `3px solid ${color.fg}`,
|
||||
borderRadius: 8,
|
||||
background: color.bg,
|
||||
color: 'inherit',
|
||||
padding: '8px 10px',
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, fontWeight: 700 }}>
|
||||
{entry.time ? `${entry.time}${entry.endTime ? `–${entry.endTime}` : ''}` : 'ohne Uhrzeit'}
|
||||
</span>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 600, lineHeight: 1.25 }}>
|
||||
{entry.customerName || entry.topic}
|
||||
</span>
|
||||
{entry.customerName && (
|
||||
<span className="faint" style={{ fontSize: 11.5, lineHeight: 1.25 }}>{entry.topic}</span>
|
||||
)}
|
||||
<span className="faint" style={{ fontSize: 11 }}>
|
||||
#{entry.woid}
|
||||
{entry.employeeName ? ` · ${entry.employeeShort || entry.employeeName}` : ' · niemand zugewiesen'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+737
-18
@@ -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>
|
||||
)
|
||||
}
|
||||
+24
-57
@@ -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>
|
||||
)
|
||||
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user