V0.1
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.git
|
||||
*.log
|
||||
.DS_Store
|
||||
docker-data
|
||||
.env
|
||||
data
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
data/
|
||||
docker-data/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
Binary file not shown.
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
# SDS CRM — Node 22 (built-in node:sqlite)
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY server ./server
|
||||
COPY public ./public
|
||||
COPY database ./database
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=8888
|
||||
|
||||
EXPOSE 8888
|
||||
|
||||
CMD ["node", "server/index.js"]
|
||||
@@ -7,6 +7,9 @@ CREATE TABLE IF NOT EXISTS "machines" (
|
||||
"typ" TEXT NOT NULL,
|
||||
"seriennummer" TEXT NOT NULL,
|
||||
"standort" TEXT NOT NULL,
|
||||
"list_status" TEXT NOT NULL DEFAULT '' CHECK (
|
||||
"list_status" IN ('', 'PRUEFEN', 'VERSCHROTTET', 'SN_GEAENDERT', 'IN_BEARBEITUNG', 'UPDATE_RAUS')
|
||||
),
|
||||
"extras" TEXT,
|
||||
"created_at" TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
"updated_at" TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
@@ -19,6 +22,8 @@ CREATE TABLE IF NOT EXISTS "tickets" (
|
||||
"description" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'OPEN' CHECK ("status" IN ('OPEN', 'WAITING', 'DONE')),
|
||||
"priority" TEXT NOT NULL DEFAULT 'MEDIUM' CHECK ("priority" IN ('LOW', 'MEDIUM', 'HIGH')),
|
||||
"sla_days" INTEGER,
|
||||
"sla_anchor_at" TEXT,
|
||||
"created_at" TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
"updated_at" TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY ("machine_id") REFERENCES "machines" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
@@ -27,12 +32,13 @@ CREATE TABLE IF NOT EXISTS "tickets" (
|
||||
CREATE TABLE IF NOT EXISTS "events" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"ticket_id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL CHECK ("type" IN ('NOTE', 'CALL', 'REMOTE', 'PART', 'SYSTEM')),
|
||||
"type" TEXT NOT NULL CHECK ("type" IN ('NOTE', 'CALL', 'REMOTE', 'PART', 'SYSTEM', 'ATTACHMENT')),
|
||||
"description" TEXT NOT NULL,
|
||||
"callback_number" TEXT,
|
||||
"teamviewer_id" TEXT,
|
||||
"article_number" TEXT,
|
||||
"remote_duration_seconds" INTEGER,
|
||||
"teamviewer_notes" TEXT,
|
||||
"created_at" TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY ("ticket_id") REFERENCES "tickets" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
@@ -43,6 +49,19 @@ CREATE INDEX IF NOT EXISTS "tickets_priority_idx" ON "tickets" ("priority");
|
||||
CREATE INDEX IF NOT EXISTS "events_ticket_id_idx" ON "events" ("ticket_id");
|
||||
CREATE INDEX IF NOT EXISTS "events_created_at_idx" ON "events" ("created_at");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "ticket_attachments" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"event_id" TEXT NOT NULL,
|
||||
"original_name" TEXT NOT NULL,
|
||||
"stored_path" TEXT NOT NULL,
|
||||
"mime_type" TEXT,
|
||||
"size_bytes" INTEGER NOT NULL,
|
||||
"created_at" TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY ("event_id") REFERENCES "events" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "ticket_attachments_event_idx" ON "ticket_attachments" ("event_id");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "users" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"username" TEXT NOT NULL UNIQUE,
|
||||
|
||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
crm:
|
||||
build: .
|
||||
image: sds-crm:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-8888}:8888"
|
||||
environment:
|
||||
PORT: ${PORT:-8888}
|
||||
# Persistente SQLite-Datei und Uploads auf dem Host (Volume unten)
|
||||
SQLITE_PATH: /data/crm.db
|
||||
UPLOAD_DIR: /data/uploads
|
||||
NODE_ENV: production
|
||||
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||
volumes:
|
||||
# Host-Verzeichnis: hier liegt die Datenbank (und uploads/) dauerhaft
|
||||
- ${CRM_DATA_DIR:-./docker-data}:/data
|
||||
372
package-lock.json
generated
372
package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.19.0",
|
||||
"ldapjs": "^3.0.7",
|
||||
"multer": "^2.0.0-rc.3",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"engines": {
|
||||
@@ -115,6 +116,24 @@
|
||||
"deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tokenizer/token": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
|
||||
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/abstract-logging": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||
@@ -143,6 +162,15 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-2.0.0.tgz",
|
||||
"integrity": "sha512-yUPXgerKgcuwakzrRyklfhX+Ma2aYYMjb+BO2RPUwq+tk928V/i5DFWcCUS3hQhj468N+Ktmwb0tfbEtmfC6WA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -170,6 +198,38 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/base32-encode": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/base32-encode/-/base32-encode-2.0.0.tgz",
|
||||
"integrity": "sha512-mlmkfc2WqdDtMl/id4qm3A7RjW6jxcbAoMjdRmsPiwQP0ufD4oXItYMnPgVHe80lnAIy+1xwzhHE1s4FoIceSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-data-view": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcrypt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
@@ -208,6 +268,41 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-0.3.1.tgz",
|
||||
"integrity": "sha512-y7tTxhGKXcyBxRKAni+awqx8uqaJKrSFSNFSeRG5CsWNdmy2BIK+6VGWEW7TZnIO/533mtMEA4rOevQV815YJw==",
|
||||
"dependencies": {
|
||||
"dicer": "0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -367,6 +462,17 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dicer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.0.tgz",
|
||||
"integrity": "sha512-MdceRRWqltEG2dZqO769g27N/3PXfcKl04VhYnBlo2YhH7zPi88VebsjTKclaOyiuMaGU72hTfw3VkUitGcVCA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "0.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
@@ -399,6 +505,15 @@
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encode-utf8": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encode-utf8/-/encode-utf8-2.0.0.tgz",
|
||||
"integrity": "sha512-3EyMFxZj1/7oMotElDQUEQcP7N4TIe1aJ0m1uBDoyQ8I2LBHhBsXx8P3KsPbqNlGzG+NYxFwEauUwMPHZg3YDQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
@@ -453,6 +568,24 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
@@ -531,6 +664,23 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-type": {
|
||||
"version": "16.5.4",
|
||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz",
|
||||
"integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readable-web-to-node-stream": "^3.0.0",
|
||||
"strtok3": "^6.2.4",
|
||||
"token-types": "^4.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
@@ -549,6 +699,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fmix": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fmix/-/fmix-1.0.0.tgz",
|
||||
"integrity": "sha512-PIaqOGvVH5P+R92Ywy5PumsNEHvondVQh42SGOmkA9A0ZTFbfguzZpjZ/Gy3WVRUqT9Ia8k5tWlJeiZQzRHA7g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -576,6 +735,18 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-temp": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fs-temp/-/fs-temp-2.0.1.tgz",
|
||||
"integrity": "sha512-WYE7cUGOA0xRKsiYxNf/+WDuj0T20OtX85bVhsXpY+wJmjrRIUQftTI6JF9PVpo2RpGkh0l8lZsV8zszisYQ0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"random-path": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -634,6 +805,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-own-property": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-own-property/-/has-own-property-2.0.0.tgz",
|
||||
"integrity": "sha512-oupojxEPq/nfAshi0hOFDjpmYO4JXtymFyPC9YSBmFqGamZ2zUgrCkYInt5tf7f1j6iumlzJBpfLNRxZIQCM0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
@@ -690,6 +870,26 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -803,6 +1003,39 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-wg2wN2xaS8Xrowj9y25jCVvhUQtIXNzVgWd1VIb7kdwilzpnA/teiZbh0KcOfd3y+RR1DiCXIzmfnWeky1i0Ag==",
|
||||
"deprecated": "You should upgrade to 3.0.0-alpha.1 version",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^2.0.0",
|
||||
"busboy": "^0.3.1",
|
||||
"bytes": "^3.1.0",
|
||||
"fs-temp": "^2.0.0",
|
||||
"has-own-property": "^2.0.0",
|
||||
"on-finished": "^2.3.0",
|
||||
"stream-file-type": "^0.6.1",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/murmur-32": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/murmur-32/-/murmur-32-1.0.0.tgz",
|
||||
"integrity": "sha512-l6QKUGWXzZWHash7lmZzycpIifOrnc3PeMaoFrMv90c+xqOyOSzJU0q2T/1d15MzAdEWTpCh1paC855APqt1Gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encode-utf8": "^2.0.0",
|
||||
"fmix": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@@ -898,6 +1131,19 @@
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/peek-readable": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz",
|
||||
"integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/precond": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz",
|
||||
@@ -906,6 +1152,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.3.2.tgz",
|
||||
@@ -949,6 +1204,19 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/random-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/random-path/-/random-path-1.0.0.tgz",
|
||||
"integrity": "sha512-I6FGG7uFR3tZqHP7TzcP3Ikt5IyVEG59u7KTjIIjizcdPY6MDjD9CbbKqE+znIv4mrDF6HMlBshoemk0oRRwsQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base32-encode": "^2.0.0",
|
||||
"murmur-32": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@@ -973,6 +1241,38 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"events": "^3.3.0",
|
||||
"process": "^0.11.10",
|
||||
"string_decoder": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-web-to-node-stream": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz",
|
||||
"integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readable-stream": "^4.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -1143,6 +1443,61 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stream-file-type": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/stream-file-type/-/stream-file-type-0.6.1.tgz",
|
||||
"integrity": "sha512-//KIXMQan4ow4gD//dfPu15nhH/sFdt41PzAOpD9EBFUBy/MtFjocTPO8v1dTOJnyi47TlPo6Qj+67sSE1lJKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-type": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz",
|
||||
"integrity": "sha512-jos8u++JKm0ARcSUTAZXOVC0mSox7Bhn6sBgty73P1f3JGf7yG2clTbBNHUdde/kdvP2FESam+vM6l8jBrNxHA==",
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strtok3": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
|
||||
"integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tokenizer/token": "^0.3.0",
|
||||
"peek-readable": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/to-data-view": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/to-data-view/-/to-data-view-2.0.0.tgz",
|
||||
"integrity": "sha512-RGEM5KqlPHr+WVTPmGNAXNeFEmsBnlkxXaIfEpUYV0AST2Z5W1EGq9L/MENFrMMmL2WQr1wjkmZy/M92eKhjYA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@@ -1152,6 +1507,23 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/token-types": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz",
|
||||
"integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tokenizer/token": "^0.3.0",
|
||||
"ieee754": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.19.0",
|
||||
"ldapjs": "^3.0.7",
|
||||
"multer": "^2.0.0-rc.3",
|
||||
"xlsx": "^0.18.5"
|
||||
}
|
||||
}
|
||||
|
||||
33
public/bootstrap.html
Normal file
33
public/bootstrap.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Erster Administrator — SDS CRM</title>
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
<link rel="stylesheet" href="/css/pages/auth.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1><a href="/start.html">SDS CRM</a></h1>
|
||||
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
|
||||
</header>
|
||||
<main id="app" class="main">
|
||||
<p id="page-loading" class="muted">Lade …</p>
|
||||
<div id="bootstrap-panel" class="stack auth-panel" hidden>
|
||||
<h2>Erster Administrator</h2>
|
||||
<p class="muted">
|
||||
Es ist noch kein Benutzer angelegt. Legen Sie das erste Admin-Konto an (min. 8 Zeichen Passwort).
|
||||
</p>
|
||||
<div class="card">
|
||||
<form id="form-boot" class="stack">
|
||||
<label>Benutzername <input name="username" required autocomplete="username" /></label>
|
||||
<label>Passwort <input name="password" type="password" required minlength="8" autocomplete="new-password" /></label>
|
||||
<button type="submit">Konto anlegen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="/js/pages/bootstrap.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
public/css/pages/auth.css
Normal file
1
public/css/pages/auth.css
Normal file
@@ -0,0 +1 @@
|
||||
/* Login / Bootstrap – bei Bedarf seiten-spezifische Styles */
|
||||
1
public/css/pages/detail.css
Normal file
1
public/css/pages/detail.css
Normal file
@@ -0,0 +1 @@
|
||||
/* Maschinen- und Ticket-Detail – bei Bedarf seiten-spezifische Styles */
|
||||
127
public/css/pages/machines.css
Normal file
127
public/css/pages/machines.css
Normal file
@@ -0,0 +1,127 @@
|
||||
/* Farben zentral — gleiche Werte für Zeilen und Legende */
|
||||
.machines-overview {
|
||||
--mrow-none: rgba(240, 243, 246, 0.12);
|
||||
--mrow-pruefen: rgba(229, 57, 53, 0.45);
|
||||
--mrow-verschrottet: rgba(206, 147, 216, 0.45);
|
||||
--mrow-sn: rgba(102, 187, 106, 0.4);
|
||||
--mrow-bearbeitung: rgba(255, 235, 59, 0.35);
|
||||
--mrow-update: rgba(38, 198, 218, 0.4);
|
||||
}
|
||||
|
||||
.machines-overview-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.machines-overview-title h2 {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
/* Legende oben rechts */
|
||||
.machine-legend {
|
||||
flex-shrink: 0;
|
||||
max-width: min(100%, 16rem);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.75rem 0.6rem;
|
||||
background: var(--bg-card);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.machine-legend-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-sub);
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.machine-legend-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.machine-legend-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin: 0.15rem 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.machine-legend-swatch {
|
||||
flex-shrink: 0;
|
||||
width: 1rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.machine-legend-swatch.machine-row--none {
|
||||
background: var(--mrow-none);
|
||||
}
|
||||
|
||||
.machine-legend-swatch.machine-row--pruefen {
|
||||
background: var(--mrow-pruefen);
|
||||
}
|
||||
|
||||
.machine-legend-swatch.machine-row--verschrottet {
|
||||
background: var(--mrow-verschrottet);
|
||||
}
|
||||
|
||||
.machine-legend-swatch.machine-row--sn {
|
||||
background: var(--mrow-sn);
|
||||
}
|
||||
|
||||
.machine-legend-swatch.machine-row--bearbeitung {
|
||||
background: var(--mrow-bearbeitung);
|
||||
}
|
||||
|
||||
.machine-legend-swatch.machine-row--update {
|
||||
background: var(--mrow-update);
|
||||
}
|
||||
|
||||
/* Maschinenliste: ganze Zeile gemäß Listen-Status */
|
||||
#machine-table tbody tr.machine-row--none {
|
||||
background: var(--mrow-none);
|
||||
}
|
||||
|
||||
#machine-table tbody tr.machine-row--pruefen {
|
||||
background: var(--mrow-pruefen);
|
||||
}
|
||||
|
||||
#machine-table tbody tr.machine-row--verschrottet {
|
||||
background: var(--mrow-verschrottet);
|
||||
}
|
||||
|
||||
#machine-table tbody tr.machine-row--sn {
|
||||
background: var(--mrow-sn);
|
||||
}
|
||||
|
||||
#machine-table tbody tr.machine-row--bearbeitung {
|
||||
background: var(--mrow-bearbeitung);
|
||||
}
|
||||
|
||||
#machine-table tbody tr.machine-row--update {
|
||||
background: var(--mrow-update);
|
||||
}
|
||||
|
||||
#machine-table tbody tr.machine-row--pruefen a,
|
||||
#machine-table tbody tr.machine-row--verschrottet a,
|
||||
#machine-table tbody tr.machine-row--sn a,
|
||||
#machine-table tbody tr.machine-row--bearbeitung a,
|
||||
#machine-table tbody tr.machine-row--update a {
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#machine-table tbody tr.machine-row--bearbeitung a {
|
||||
color: var(--accent-hi);
|
||||
}
|
||||
1
public/css/pages/options.css
Normal file
1
public/css/pages/options.css
Normal file
@@ -0,0 +1 @@
|
||||
/* Optionen – bei Bedarf seiten-spezifische Styles */
|
||||
1
public/css/pages/start.css
Normal file
1
public/css/pages/start.css
Normal file
@@ -0,0 +1 @@
|
||||
/* Start – bei Bedarf seiten-spezifische Styles */
|
||||
37
public/css/pages/tickets.css
Normal file
37
public/css/pages/tickets.css
Normal file
@@ -0,0 +1,37 @@
|
||||
/* Tickets – Neues Ticket: volle Breite für Titel & Beschreibung */
|
||||
|
||||
.ticket-new-form .ticket-form-machine label {
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
.ticket-new-form .ticket-form-machine select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ticket-new-form .ticket-field-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ticket-new-form .ticket-field-full input,
|
||||
.ticket-new-form .ticket-field-full textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ticket-new-form textarea.ticket-desc {
|
||||
resize: vertical;
|
||||
min-height: 8rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ticket-row-overdue {
|
||||
background-color: rgba(180, 40, 40, 0.12);
|
||||
box-shadow: inset 3px 0 0 0 #b42828;
|
||||
}
|
||||
|
||||
.ticket-row-overdue a {
|
||||
color: #8b1e1e;
|
||||
font-weight: 600;
|
||||
}
|
||||
1
public/css/pages/users.css
Normal file
1
public/css/pages/users.css
Normal file
@@ -0,0 +1 @@
|
||||
/* Benutzer – bei Bedarf seiten-spezifische Styles */
|
||||
1904
public/css/style.css
1904
public/css/style.css
File diff suppressed because it is too large
Load Diff
@@ -4,14 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SDS CRM</title>
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
<meta http-equiv="refresh" content="0;url=/start.html" />
|
||||
<script>
|
||||
location.replace('/start.html');
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1><a href="#/home">SDS CRM</a></h1>
|
||||
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
|
||||
</header>
|
||||
<main id="app" class="main"></main>
|
||||
<script type="module" src="/js/app.js"></script>
|
||||
<p><a href="/start.html">Weiter zur Startseite</a></p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,16 +19,14 @@ async function parseError(res, text) {
|
||||
}
|
||||
|
||||
function redirectToLogin() {
|
||||
if (
|
||||
!location.hash.startsWith('#/login') &&
|
||||
!location.hash.startsWith('#/bootstrap')
|
||||
) {
|
||||
location.hash = '#/login';
|
||||
const p = location.pathname;
|
||||
if (p !== '/login.html' && p !== '/bootstrap.html') {
|
||||
location.href = '/login.html';
|
||||
}
|
||||
}
|
||||
|
||||
/** Geschützte REST-API liegt unter /api (Root-URLs bleiben für die SPA frei). */
|
||||
function apiUrl(path) {
|
||||
export function apiUrl(path) {
|
||||
if (path.startsWith('/auth/')) return path;
|
||||
return `/api${path.startsWith('/') ? path : `/${path}`}`;
|
||||
}
|
||||
@@ -105,6 +103,23 @@ export async function apiDelete(path) {
|
||||
return apiRequest('DELETE', path);
|
||||
}
|
||||
|
||||
/** multipart/form-data (kein Content-Type setzen — Boundary setzt der Browser) */
|
||||
export async function apiPostForm(path, formData) {
|
||||
const url = apiUrl(path);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (res.status === 401) {
|
||||
onUnauthorized(path);
|
||||
throw authRedirectError();
|
||||
}
|
||||
if (!res.ok) throw new Error(await parseError(res, text));
|
||||
return parseJsonBody(text);
|
||||
}
|
||||
|
||||
/** Öffentlich: keine Session nötig, kein Redirect bei 401 */
|
||||
export async function authFetchStatus() {
|
||||
const res = await fetch('/auth/status', { credentials: 'include' });
|
||||
|
||||
1502
public/js/app.js
1502
public/js/app.js
File diff suppressed because it is too large
Load Diff
165
public/js/core/attachment-preview.js
Normal file
165
public/js/core/attachment-preview.js
Normal file
@@ -0,0 +1,165 @@
|
||||
/** Download-URL (href der API) → dieselbe URL mit ?inline=1 für Anzeige */
|
||||
export function hrefToInlineView(href) {
|
||||
if (!href) return href;
|
||||
const u = new URL(href, window.location.origin);
|
||||
u.searchParams.set('inline', '1');
|
||||
return u.pathname + u.search + u.hash;
|
||||
}
|
||||
|
||||
const TEXT_PREVIEW_MAX = 512 * 1024;
|
||||
|
||||
/** @param {string} mime @param {string} fileName */
|
||||
export function attachmentPreviewKind(mime, fileName) {
|
||||
const m = (mime || '').toLowerCase().trim();
|
||||
const ext = (fileName || '').split('.').pop()?.toLowerCase() || '';
|
||||
if (m.startsWith('image/')) return 'image';
|
||||
if (m === 'application/pdf' || ext === 'pdf') return 'pdf';
|
||||
if (m.startsWith('video/')) return 'video';
|
||||
if (m.startsWith('audio/')) return 'audio';
|
||||
if (
|
||||
m.startsWith('text/') ||
|
||||
m === 'application/json' ||
|
||||
m === 'application/xml' ||
|
||||
['csv', 'json', 'xml', 'txt', 'log', 'md', 'svg'].includes(ext)
|
||||
) {
|
||||
return 'text';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
let dialogEl;
|
||||
let bodyEl;
|
||||
let titleEl;
|
||||
let downloadLink;
|
||||
|
||||
function setPageScrollLocked(locked) {
|
||||
if (locked) {
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.documentElement.style.overflow = '';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDialog() {
|
||||
if (dialogEl) return;
|
||||
dialogEl = document.createElement('dialog');
|
||||
dialogEl.className = 'attachment-preview-dialog';
|
||||
dialogEl.setAttribute('aria-modal', 'true');
|
||||
dialogEl.innerHTML = `
|
||||
<div class="attachment-preview-inner">
|
||||
<header class="attachment-preview-header">
|
||||
<h3 class="attachment-preview-title"></h3>
|
||||
<button type="button" class="attachment-preview-close" aria-label="Schließen">×</button>
|
||||
</header>
|
||||
<div class="attachment-preview-body"></div>
|
||||
<footer class="attachment-preview-footer">
|
||||
<a class="button secondary attachment-preview-download" href="#" download>Herunterladen</a>
|
||||
</footer>
|
||||
</div>`;
|
||||
document.body.appendChild(dialogEl);
|
||||
bodyEl = dialogEl.querySelector('.attachment-preview-body');
|
||||
titleEl = dialogEl.querySelector('.attachment-preview-title');
|
||||
downloadLink = dialogEl.querySelector('.attachment-preview-download');
|
||||
dialogEl.querySelector('.attachment-preview-close').addEventListener('click', () => {
|
||||
dialogEl.close();
|
||||
});
|
||||
dialogEl.addEventListener('click', (e) => {
|
||||
if (e.target === dialogEl) dialogEl.close();
|
||||
});
|
||||
dialogEl.addEventListener('close', () => {
|
||||
if (bodyEl) bodyEl.innerHTML = '';
|
||||
setPageScrollLocked(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Klick-Delegation: Links mit .js-attachment-preview öffnen das Modal.
|
||||
*/
|
||||
export function bindAttachmentPreview(root = document.body) {
|
||||
ensureDialog();
|
||||
root.addEventListener('click', (e) => {
|
||||
const a = e.target.closest('a.js-attachment-preview');
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
openAttachmentPreview(a);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLAnchorElement} a
|
||||
*/
|
||||
export async function openAttachmentPreview(a) {
|
||||
ensureDialog();
|
||||
const name = a.getAttribute('data-name') || a.textContent?.trim() || 'Datei';
|
||||
const mime = a.getAttribute('data-mime') || '';
|
||||
const rawHref = a.getAttribute('href') || '';
|
||||
const viewUrl = hrefToInlineView(rawHref);
|
||||
const kind = attachmentPreviewKind(mime, name);
|
||||
|
||||
titleEl.textContent = name;
|
||||
downloadLink.href = rawHref;
|
||||
downloadLink.setAttribute('download', name);
|
||||
|
||||
bodyEl.className = 'attachment-preview-body attachment-preview-body--scroll';
|
||||
bodyEl.innerHTML = '<p class="muted attachment-preview-loading">Lade Vorschau …</p>';
|
||||
|
||||
try {
|
||||
if (kind === 'image') {
|
||||
bodyEl.innerHTML = '';
|
||||
const img = document.createElement('img');
|
||||
img.className = 'attachment-preview-img';
|
||||
img.alt = name;
|
||||
img.src = viewUrl;
|
||||
img.referrerPolicy = 'same-origin';
|
||||
bodyEl.appendChild(img);
|
||||
} else if (kind === 'pdf') {
|
||||
bodyEl.className = 'attachment-preview-body attachment-preview-body--embed';
|
||||
bodyEl.innerHTML = '';
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.className = 'attachment-preview-iframe';
|
||||
iframe.title = name;
|
||||
iframe.src = viewUrl;
|
||||
bodyEl.appendChild(iframe);
|
||||
} else if (kind === 'video') {
|
||||
bodyEl.innerHTML = '';
|
||||
const v = document.createElement('video');
|
||||
v.className = 'attachment-preview-video';
|
||||
v.controls = true;
|
||||
v.playsInline = true;
|
||||
v.src = viewUrl;
|
||||
bodyEl.appendChild(v);
|
||||
} else if (kind === 'audio') {
|
||||
bodyEl.innerHTML = '';
|
||||
const v = document.createElement('audio');
|
||||
v.className = 'attachment-preview-audio';
|
||||
v.controls = true;
|
||||
v.src = viewUrl;
|
||||
bodyEl.appendChild(v);
|
||||
} else if (kind === 'text') {
|
||||
const res = await fetch(viewUrl, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error('Laden fehlgeschlagen');
|
||||
let text = await res.text();
|
||||
if (text.length > TEXT_PREVIEW_MAX) {
|
||||
text = `${text.slice(0, TEXT_PREVIEW_MAX)}\n\n… (gekürzt)`;
|
||||
}
|
||||
bodyEl.innerHTML = '';
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'attachment-preview-text';
|
||||
pre.textContent = text;
|
||||
bodyEl.appendChild(pre);
|
||||
} else {
|
||||
bodyEl.innerHTML =
|
||||
'<p class="muted">Für diesen Dateityp gibt es keine eingebaute Vorschau. Nutzen Sie „Herunterladen“.</p>';
|
||||
}
|
||||
} catch (err) {
|
||||
bodyEl.className = 'attachment-preview-body attachment-preview-body--scroll';
|
||||
bodyEl.innerHTML = `<p class="error">Vorschau konnte nicht geladen werden: ${err.message || err}</p>`;
|
||||
}
|
||||
|
||||
if (typeof dialogEl.showModal === 'function') {
|
||||
setPageScrollLocked(true);
|
||||
dialogEl.showModal();
|
||||
}
|
||||
}
|
||||
49
public/js/core/auth-guard.js
Normal file
49
public/js/core/auth-guard.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { authFetchStatus, isAuthRedirectError } from '../api.js';
|
||||
import { updateNav } from './layout.js';
|
||||
|
||||
window.addEventListener('unhandledrejection', (ev) => {
|
||||
if (isAuthRedirectError(ev.reason)) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {{ needsAdmin?: boolean, activeNav?: string }} opts
|
||||
* @returns {Promise<object|null>} Session-Status oder null bei Redirect
|
||||
*/
|
||||
export async function guard(opts = {}) {
|
||||
const { needsAdmin = false, activeNav = '' } = opts;
|
||||
let st;
|
||||
try {
|
||||
st = await authFetchStatus();
|
||||
} catch {
|
||||
st = { needsBootstrap: false, loggedIn: false, user: null };
|
||||
}
|
||||
if (st.needsBootstrap) {
|
||||
if (!location.pathname.endsWith('/bootstrap.html')) {
|
||||
location.href = '/bootstrap.html';
|
||||
return null;
|
||||
}
|
||||
return st;
|
||||
}
|
||||
if (!st.loggedIn) {
|
||||
if (!location.pathname.endsWith('/login.html')) {
|
||||
location.href = '/login.html';
|
||||
return null;
|
||||
}
|
||||
return st;
|
||||
}
|
||||
if (
|
||||
location.pathname.endsWith('/login.html') ||
|
||||
location.pathname.endsWith('/bootstrap.html')
|
||||
) {
|
||||
location.href = '/start.html';
|
||||
return null;
|
||||
}
|
||||
if (needsAdmin && st.user?.role !== 'admin') {
|
||||
location.href = '/start.html';
|
||||
return null;
|
||||
}
|
||||
updateNav(st, activeNav);
|
||||
return st;
|
||||
}
|
||||
64
public/js/core/constants.js
Normal file
64
public/js/core/constants.js
Normal file
@@ -0,0 +1,64 @@
|
||||
export const ticketStatusLabel = {
|
||||
OPEN: 'Offen',
|
||||
WAITING: 'Warte auf Rückmeldung',
|
||||
DONE: 'Erledigt',
|
||||
};
|
||||
|
||||
export const ticketPriorityLabel = {
|
||||
LOW: 'Niedrig',
|
||||
MEDIUM: 'Mittel',
|
||||
HIGH: 'Hoch',
|
||||
};
|
||||
|
||||
export const eventTypeLabel = {
|
||||
NOTE: 'Notiz',
|
||||
CALL: 'Anruf',
|
||||
REMOTE: 'Remote',
|
||||
PART: 'Ersatzteil benötigt',
|
||||
ATTACHMENT: 'Anhang',
|
||||
SYSTEM: 'System',
|
||||
};
|
||||
|
||||
export const eventTypeBadgeClass = {
|
||||
NOTE: 'event-type-note',
|
||||
CALL: 'event-type-call',
|
||||
REMOTE: 'event-type-remote',
|
||||
PART: 'event-type-part',
|
||||
ATTACHMENT: 'event-type-attachment',
|
||||
SYSTEM: 'event-type-system',
|
||||
};
|
||||
|
||||
export const statusBadgeClass = {
|
||||
OPEN: 'badge-open',
|
||||
WAITING: 'badge-waiting',
|
||||
DONE: 'badge-done',
|
||||
};
|
||||
|
||||
export const priorityBadgeClass = {
|
||||
HIGH: 'badge-high',
|
||||
MEDIUM: 'badge-medium',
|
||||
LOW: 'badge-low',
|
||||
};
|
||||
|
||||
/** Einheitlicher Platzhalter, wenn noch keine Gruppenzeile gesetzt war */
|
||||
export const ANLAGEN_GRUPPE_PLACEHOLDER = '\u2014';
|
||||
|
||||
/** Listen-Status Maschinen (Anlagenliste) — Codes wie in der API/DB */
|
||||
export const machineListStatusLabel = {
|
||||
'': '',
|
||||
PRUEFEN: 'Prüfen!',
|
||||
VERSCHROTTET: 'Verschrottet',
|
||||
SN_GEAENDERT: 'SN geändert',
|
||||
IN_BEARBEITUNG: 'In Bearbeitung',
|
||||
UPDATE_RAUS: 'Update raus',
|
||||
};
|
||||
|
||||
/** Zeilenklasse in der Maschinenliste (volle Zeilenfarbe) */
|
||||
export const machineListStatusRowClass = {
|
||||
'': 'machine-row--none',
|
||||
PRUEFEN: 'machine-row--pruefen',
|
||||
VERSCHROTTET: 'machine-row--verschrottet',
|
||||
SN_GEAENDERT: 'machine-row--sn',
|
||||
IN_BEARBEITUNG: 'machine-row--bearbeitung',
|
||||
UPDATE_RAUS: 'machine-row--update',
|
||||
};
|
||||
39
public/js/core/layout.js
Normal file
39
public/js/core/layout.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { apiPost } from '../api.js';
|
||||
import { esc } from './utils.js';
|
||||
|
||||
/**
|
||||
* @param {string} [activeNav] 'start' | 'machines' | 'tickets' | 'options' | 'users'
|
||||
*/
|
||||
export function updateNav(st, activeNav = '') {
|
||||
const nav = document.getElementById('main-nav');
|
||||
if (!nav) return;
|
||||
if (!st.loggedIn) {
|
||||
nav.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const isAdmin = st.user?.role === 'admin';
|
||||
const na = (key) => (activeNav === key ? 'nav-active' : '');
|
||||
nav.innerHTML = `
|
||||
<a href="/start.html" class="${na('start')}">Start</a>
|
||||
<a href="/machines.html" class="${na('machines')}">Maschinen</a>
|
||||
<a href="/tickets.html" class="${na('tickets')}">Tickets</a>
|
||||
${
|
||||
isAdmin
|
||||
? `<a href="/options.html" class="${na('options')}">Optionen</a><a href="/users.html" class="${na('users')}">Benutzer</a>`
|
||||
: ''
|
||||
}
|
||||
<span class="nav-user muted">${esc(st.user.username)}</span>
|
||||
<button type="button" class="secondary btn-nav-logout" id="btn-logout">Abmelden</button>`;
|
||||
const btn = document.getElementById('btn-logout');
|
||||
if (btn) {
|
||||
btn.onclick = async () => {
|
||||
try {
|
||||
await apiPost('/auth/logout', {});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
updateNav({ loggedIn: false });
|
||||
location.href = '/login.html';
|
||||
};
|
||||
}
|
||||
}
|
||||
247
public/js/core/machine-extras.js
Normal file
247
public/js/core/machine-extras.js
Normal file
@@ -0,0 +1,247 @@
|
||||
import { esc } from './utils.js';
|
||||
import { ANLAGEN_GRUPPE_PLACEHOLDER } from './constants.js';
|
||||
|
||||
/** Baut das extras-Objekt aus dem Maschinen-Bearbeitungsformular (Anlagenliste). */
|
||||
export function collectExtrasFromMachineForm(form, machine) {
|
||||
const prev =
|
||||
machine.extras && typeof machine.extras === 'object'
|
||||
? JSON.parse(JSON.stringify(machine.extras))
|
||||
: {};
|
||||
const beschr = prev._beschriftungZeile9;
|
||||
const werte = prev._werteAlsListe;
|
||||
const gruppe = prev._gruppeZeile7;
|
||||
if (
|
||||
Array.isArray(beschr) &&
|
||||
Array.isArray(werte) &&
|
||||
beschr.length === werte.length
|
||||
) {
|
||||
const n = beschr.length;
|
||||
const newWerte = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
newWerte.push(String(form.elements[`extra_wert_${i}`]?.value ?? ''));
|
||||
}
|
||||
prev._werteAlsListe = newWerte;
|
||||
return prev;
|
||||
}
|
||||
const out = {};
|
||||
for (const k of Object.keys(prev)) {
|
||||
if (k.startsWith('_')) out[k] = prev[k];
|
||||
}
|
||||
const kvKeys = Object.keys(prev).filter((k) => !k.startsWith('_'));
|
||||
for (let i = 0; i < kvKeys.length; i++) {
|
||||
const key = kvKeys[i];
|
||||
out[key] = String(form.elements[`extras_kv_val_${i}`]?.value ?? '');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function extrasTableHtml(extras, opts) {
|
||||
const editable = opts && opts.editable === true;
|
||||
if (!extras || typeof extras !== 'object') {
|
||||
if (editable) {
|
||||
return `
|
||||
<div class="card">
|
||||
<h3>Anlagenliste (ITT)</h3>
|
||||
<p class="muted">Keine Anlagendaten gespeichert — nichts zu bearbeiten.</p>
|
||||
</div>`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const beschr = extras._beschriftungZeile9;
|
||||
const gruppe = extras._gruppeZeile7;
|
||||
const werte = extras._werteAlsListe;
|
||||
|
||||
if (
|
||||
Array.isArray(beschr) &&
|
||||
Array.isArray(werte) &&
|
||||
beschr.length === werte.length
|
||||
) {
|
||||
const g =
|
||||
Array.isArray(gruppe) && gruppe.length === beschr.length
|
||||
? gruppe
|
||||
: null;
|
||||
|
||||
if (editable) {
|
||||
const body = beschr
|
||||
.map((label, i) => {
|
||||
const v = werte[i];
|
||||
const grp = g ? String(g[i] ?? '') : '';
|
||||
return `<tr>
|
||||
<td class="muted">${esc(grp)}</td>
|
||||
<th>${esc(String(label || `Spalte ${i + 1}`))}</th>
|
||||
<td><input type="text" name="extra_wert_${i}" value="${esc(String(v ?? ''))}" class="extras-cell-input" autocomplete="off" /></td>
|
||||
</tr>`;
|
||||
})
|
||||
.join('');
|
||||
return `
|
||||
<div class="card">
|
||||
<h3>Anlagenliste (bearbeiten)</h3>
|
||||
<p class="muted">Nur die Spalte „Wert“ ist bearbeitbar. Gruppe und Beschreibung bleiben wie importiert.</p>
|
||||
<div class="table-wrap">
|
||||
<table class="extras-table anlagen-voll">
|
||||
<colgroup>
|
||||
<col class="anlagen-col-gruppe" />
|
||||
<col class="anlagen-col-beschr" />
|
||||
<col class="anlagen-col-wert" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Gruppe (Z. 7)</th>
|
||||
<th>Beschreibung (Z. 9)</th>
|
||||
<th>Wert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${body}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (g) {
|
||||
const n = beschr.length;
|
||||
const groupForRow = [];
|
||||
let cur = '';
|
||||
for (let i = 0; i < n; i++) {
|
||||
const raw = g[i] != null ? String(g[i]).trim() : '';
|
||||
if (raw) cur = raw;
|
||||
groupForRow[i] = cur || ANLAGEN_GRUPPE_PLACEHOLDER;
|
||||
}
|
||||
const segments = [];
|
||||
let segStart = 0;
|
||||
for (let i = 1; i <= n; i++) {
|
||||
if (i === n || groupForRow[i] !== groupForRow[i - 1]) {
|
||||
segments.push({ from: segStart, to: i, key: groupForRow[segStart] });
|
||||
segStart = i;
|
||||
}
|
||||
}
|
||||
|
||||
const gruppenTitel = (key) =>
|
||||
key === ANLAGEN_GRUPPE_PLACEHOLDER ? 'Sonstiges' : key;
|
||||
|
||||
const tbodies = segments
|
||||
.map((seg, idx) => {
|
||||
const spacer =
|
||||
idx > 0
|
||||
? `<tbody class="anlagen-gruppe-spacer"><tr><td colspan="2"></td></tr></tbody>`
|
||||
: '';
|
||||
const titel = gruppenTitel(seg.key);
|
||||
const kopf = `<tr class="anlagen-gruppe-kopf"><td colspan="2">${esc(titel)}</td></tr>`;
|
||||
const zeilen = [];
|
||||
for (let i = seg.from; i < seg.to; i++) {
|
||||
const v = werte[i];
|
||||
const show = v !== '' && v != null;
|
||||
const label = String(beschr[i] || `Spalte ${i + 1}`);
|
||||
zeilen.push(
|
||||
`<tr data-empty="${show ? '0' : '1'}"><th>${esc(label)}</th><td>${esc(String(v ?? ''))}</td></tr>`,
|
||||
);
|
||||
}
|
||||
return `${spacer}<tbody class="anlagen-gruppe">${kopf}${zeilen.join('')}</tbody>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div class="card">
|
||||
<h3>Anlagenliste</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="extras-table anlagen-voll gruppiert">
|
||||
<colgroup>
|
||||
<col class="anlagen-col-beschr" />
|
||||
<col class="anlagen-col-wert" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Beschreibung (Z. 9)</th>
|
||||
<th>Wert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
${tbodies}
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const body = beschr
|
||||
.map((label, i) => {
|
||||
const v = werte[i];
|
||||
const show = v !== '' && v != null;
|
||||
const grp = '';
|
||||
return `<tr data-empty="${show ? '0' : '1'}">
|
||||
<td class="muted">${esc(grp)}</td>
|
||||
<th>${esc(String(label || `Spalte ${i + 1}`))}</th>
|
||||
<td>${esc(String(v ?? ''))}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join('');
|
||||
return `
|
||||
<div class="card">
|
||||
<h3>Anlagenliste (alle Spalten · Zeile 9 = Beschreibung)</h3>
|
||||
<p class="muted">Gruppe = Zeile 7 im Blatt „Anlagen“, Beschreibung = Zeile 9.</p>
|
||||
<div class="table-wrap">
|
||||
<table class="extras-table anlagen-voll">
|
||||
<colgroup>
|
||||
<col class="anlagen-col-gruppe" />
|
||||
<col class="anlagen-col-beschr" />
|
||||
<col class="anlagen-col-wert" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Gruppe (Z. 7)</th>
|
||||
<th>Beschreibung (Z. 9)</th>
|
||||
<th>Wert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${body}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const kvEntries = Object.entries(extras).filter(([k]) => !k.startsWith('_'));
|
||||
if (editable) {
|
||||
if (kvEntries.length === 0) {
|
||||
return `
|
||||
<div class="card">
|
||||
<h3>Daten aus Anlagenliste (ITT)</h3>
|
||||
<p class="muted">Keine zusätzlichen Felder — nichts zu bearbeiten.</p>
|
||||
</div>`;
|
||||
}
|
||||
const rows = kvEntries
|
||||
.map(
|
||||
([k, v], i) =>
|
||||
`<tr>
|
||||
<th>${esc(k)}</th>
|
||||
<td><input type="text" name="extras_kv_val_${i}" value="${esc(String(v))}" class="extras-cell-input" autocomplete="off" /></td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
return `
|
||||
<div class="card">
|
||||
<h3>Daten aus Anlagenliste (ITT)</h3>
|
||||
<p class="muted">Nur die Werte sind bearbeitbar; Feldnamen sind fest.</p>
|
||||
<div class="table-wrap">
|
||||
<table class="extras-table">
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const rows = kvEntries
|
||||
.filter(([, v]) => v !== '' && v != null)
|
||||
.map(
|
||||
([k, v]) =>
|
||||
`<tr><th>${esc(k)}</th><td>${esc(String(v))}</td></tr>`,
|
||||
)
|
||||
.join('');
|
||||
if (!rows) return '';
|
||||
return `
|
||||
<div class="card">
|
||||
<h3>Daten aus Anlagenliste (ITT)</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="extras-table">
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
220
public/js/core/ticket-events.js
Normal file
220
public/js/core/ticket-events.js
Normal file
@@ -0,0 +1,220 @@
|
||||
import { apiGet, apiUrl } from '../api.js';
|
||||
import { esc, formatDateTime, formatRemoteDurationDe, telHref } from './utils.js';
|
||||
|
||||
function formatFileSizeDe(n) {
|
||||
if (n == null || typeof n !== 'number' || n < 0) return '';
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Zwischenspeicher für GET /integrations/teamviewer/connections */
|
||||
let tvSessionsCache = null;
|
||||
|
||||
/** HTML für die Inhaltsspalte (nur server-/formularbekannte Typen) */
|
||||
export function eventInhaltHtml(ev) {
|
||||
const t = ev.type;
|
||||
if (t === 'CALL') {
|
||||
let h = `<div class="event-inhalt-block"><p class="event-inhalt-label">Beschreibung</p><div class="event-inhalt-text">${esc(ev.description)}</div>`;
|
||||
if (ev.callbackNumber) {
|
||||
const th = telHref(ev.callbackNumber);
|
||||
const numHtml = th
|
||||
? `<a href="${esc(th)}">${esc(ev.callbackNumber)}</a>`
|
||||
: esc(ev.callbackNumber);
|
||||
h += `<p class="event-inhalt-meta"><strong>Rückrufnummer:</strong> ${numHtml}</p>`;
|
||||
}
|
||||
return `${h}</div>`;
|
||||
}
|
||||
if (t === 'REMOTE') {
|
||||
let h = `<div class="event-inhalt-block"><p class="event-inhalt-label">Beschreibung</p><div class="event-inhalt-text">${esc(ev.description)}</div>`;
|
||||
if (ev.teamviewerId) {
|
||||
h += `<p class="event-inhalt-meta"><strong>Gerät-ID (TeamViewer):</strong> <code>${esc(ev.teamviewerId)}</code></p>`;
|
||||
}
|
||||
if (ev.remoteDurationSeconds != null) {
|
||||
h += `<p class="event-inhalt-meta"><strong>Remote-Dauer:</strong> ${esc(formatRemoteDurationDe(ev.remoteDurationSeconds))}</p>`;
|
||||
}
|
||||
if (ev.teamviewerNotes && String(ev.teamviewerNotes).trim()) {
|
||||
h += `<p class="event-inhalt-meta"><strong>Notizen:</strong> <span class="event-tv-notes" style="white-space:pre-wrap">${esc(String(ev.teamviewerNotes).trim())}</span></p>`;
|
||||
}
|
||||
return `${h}</div>`;
|
||||
}
|
||||
if (t === 'PART') {
|
||||
let h = `<div class="event-inhalt-block"><p class="event-inhalt-meta"><strong>Artikelnummer:</strong> <code class="event-artnr">${esc(ev.articleNumber || '')}</code></p>`;
|
||||
if (ev.description && String(ev.description).trim()) {
|
||||
h += `<p class="event-inhalt-label">Bemerkung</p><div class="event-inhalt-text">${esc(ev.description)}</div>`;
|
||||
}
|
||||
return `${h}</div>`;
|
||||
}
|
||||
if (t === 'ATTACHMENT') {
|
||||
let h = '';
|
||||
if (ev.description && String(ev.description).trim()) {
|
||||
h += `<div class="event-inhalt-block"><p class="event-inhalt-label">Beschreibung</p><div class="event-inhalt-text">${esc(ev.description)}</div></div>`;
|
||||
}
|
||||
const atts = ev.attachments || [];
|
||||
if (atts.length === 0) {
|
||||
h += '<p class="muted">Keine Dateien.</p>';
|
||||
} else {
|
||||
h += '<ul class="event-attachment-list">';
|
||||
for (const a of atts) {
|
||||
const href = apiUrl(a.url);
|
||||
const timeParen =
|
||||
a.createdAt != null
|
||||
? ` <span class="muted">(${esc(formatDateTime(a.createdAt))})</span>`
|
||||
: '';
|
||||
h += `<li><a href="${esc(href)}" class="js-attachment-preview" data-mime="${esc(a.mimeType || '')}" data-name="${esc(a.originalName)}">${esc(a.originalName)}</a>${timeParen}`;
|
||||
if (a.sizeBytes != null) {
|
||||
h += ` <span class="muted">· ${esc(formatFileSizeDe(a.sizeBytes))}</span>`;
|
||||
}
|
||||
h += '</li>';
|
||||
}
|
||||
h += '</ul>';
|
||||
}
|
||||
return h;
|
||||
}
|
||||
return `<div class="event-inhalt-text">${esc(ev.description)}</div>`;
|
||||
}
|
||||
|
||||
export function fillTvDeviceSelect() {
|
||||
const userSel = document.getElementById('tv-user-select');
|
||||
const devSel = document.getElementById('tv-conn-select');
|
||||
if (!devSel) return;
|
||||
const ukey = userSel?.value ?? '';
|
||||
devSel.innerHTML =
|
||||
'<option value="">— Gerät / Session wählen —</option>';
|
||||
if (!ukey || !tvSessionsCache) {
|
||||
devSel.disabled = true;
|
||||
return;
|
||||
}
|
||||
const u = (tvSessionsCache.users || []).find((x) => x.userKey === ukey);
|
||||
const devices = u?.devices || [];
|
||||
if (devices.length === 0) {
|
||||
devSel.disabled = true;
|
||||
return;
|
||||
}
|
||||
devSel.disabled = false;
|
||||
devSel.innerHTML +=
|
||||
devices
|
||||
.map(
|
||||
(d) =>
|
||||
`<option value="${esc(d.deviceid)}" data-devicename="${esc(d.label)}" data-start-date="${esc(d.startDate || '')}" data-end-date="${esc(d.endDate || '')}" data-notes="${esc(d.notes || '')}">${esc(d.label)}</option>`,
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
export async function loadTeamViewerConnectionsIntoSelect() {
|
||||
const userSel = document.getElementById('tv-user-select');
|
||||
const devSel = document.getElementById('tv-conn-select');
|
||||
const hint = document.getElementById('tv-conn-hint');
|
||||
if (!userSel || !devSel) return;
|
||||
userSel.innerHTML = '<option value="">… lädt …</option>';
|
||||
devSel.innerHTML = '<option value="">…</option>';
|
||||
devSel.disabled = true;
|
||||
if (hint) hint.textContent = '';
|
||||
try {
|
||||
const data = await apiGet('/integrations/teamviewer/connections');
|
||||
tvSessionsCache = data;
|
||||
const users = data.users || [];
|
||||
userSel.innerHTML =
|
||||
'<option value="">— Benutzer wählen (letzte 7 Tage) —</option>' +
|
||||
users
|
||||
.map((u) => {
|
||||
const label =
|
||||
u.username && u.username !== '_unbekannt'
|
||||
? u.username
|
||||
: u.userid
|
||||
? `Benutzer ${u.userid}`
|
||||
: 'Unbekannt (Benutzer)';
|
||||
return `<option value="${esc(u.userKey)}">${esc(label)}</option>`;
|
||||
})
|
||||
.join('');
|
||||
devSel.innerHTML =
|
||||
'<option value="">— zuerst Benutzer wählen —</option>';
|
||||
devSel.disabled = true;
|
||||
userSel.onchange = () => fillTvDeviceSelect();
|
||||
if (hint) {
|
||||
const ndev = users.reduce((n, u) => n + (u.devices?.length || 0), 0);
|
||||
if (users.length) {
|
||||
hint.textContent = `${users.length} Benutzer, ${ndev} Gerät(e)/Session(s).`;
|
||||
} else {
|
||||
hint.textContent =
|
||||
data.meta?.recordCount === 0
|
||||
? 'Keine Verbindungen in den letzten 7 Tagen.'
|
||||
: 'Keine gruppierten Einträge (TeamViewer-Antwort prüfen).';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
tvSessionsCache = null;
|
||||
userSel.innerHTML = '<option value="">— Laden fehlgeschlagen —</option>';
|
||||
devSel.innerHTML = '<option value="">—</option>';
|
||||
devSel.disabled = true;
|
||||
if (hint) hint.textContent = e.message || 'Fehler';
|
||||
}
|
||||
}
|
||||
|
||||
export function syncEventFormFieldGroups(form) {
|
||||
const sel = form.querySelector('#ev-type-sel');
|
||||
if (!sel) return;
|
||||
const v = sel.value;
|
||||
form.querySelectorAll('.ev-field-group').forEach((el) => {
|
||||
const show = el.getAttribute('data-ev-type') === v;
|
||||
el.hidden = !show;
|
||||
el.querySelectorAll('input, textarea').forEach((inp) => {
|
||||
const name = inp.getAttribute('name');
|
||||
let req = false;
|
||||
if (show) {
|
||||
if (v === 'NOTE' && name === 'description_note') req = true;
|
||||
if (v === 'CALL' && name === 'description_call') req = true;
|
||||
if (v === 'REMOTE' && name === 'description_remote') req = false;
|
||||
if (v === 'PART' && name === 'articleNumber') req = true;
|
||||
if (v === 'ATTACHMENT') req = false;
|
||||
}
|
||||
inp.required = req;
|
||||
});
|
||||
});
|
||||
if (v === 'REMOTE') {
|
||||
loadTeamViewerConnectionsIntoSelect();
|
||||
}
|
||||
}
|
||||
|
||||
/** Neueste zuerst; ATTACHMENT-Blöcke bleiben unten (innerhalb ebenfalls neuer über älter). */
|
||||
export function sortEventsChronologicalWithAttachmentsLast(events) {
|
||||
const non = events
|
||||
.filter((e) => e.type !== 'ATTACHMENT')
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
const att = events
|
||||
.filter((e) => e.type === 'ATTACHMENT')
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
return [...non, ...att];
|
||||
}
|
||||
|
||||
export function buildEventPostBody(ticketId, fd) {
|
||||
const type = fd.get('type');
|
||||
const base = { ticketId, type };
|
||||
if (type === 'NOTE') {
|
||||
return { ...base, description: fd.get('description_note') };
|
||||
}
|
||||
if (type === 'CALL') {
|
||||
return {
|
||||
...base,
|
||||
description: fd.get('description_call'),
|
||||
callbackNumber: fd.get('callbackNumber'),
|
||||
};
|
||||
}
|
||||
if (type === 'REMOTE') {
|
||||
const body = {
|
||||
...base,
|
||||
description: fd.get('description_remote'),
|
||||
};
|
||||
const tv = fd.get('teamviewerDevice');
|
||||
if (tv && String(tv).trim()) body.teamviewerId = String(tv).trim();
|
||||
return body;
|
||||
}
|
||||
if (type === 'PART') {
|
||||
return {
|
||||
...base,
|
||||
articleNumber: fd.get('articleNumber'),
|
||||
description: fd.get('description_part') || '',
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}
|
||||
60
public/js/core/utils.js
Normal file
60
public/js/core/utils.js
Normal file
@@ -0,0 +1,60 @@
|
||||
export function esc(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** Rufnummer für href="tel:…" (Ziffern, höchstens ein führendes +). */
|
||||
export function telHref(raw) {
|
||||
const t = String(raw ?? '').trim();
|
||||
if (!t) return '';
|
||||
const digitsPlus = t.replace(/[^\d+]/g, '');
|
||||
if (!digitsPlus) return '';
|
||||
const normalized = digitsPlus.startsWith('+')
|
||||
? '+' + digitsPlus.slice(1).replace(/\+/g, '')
|
||||
: digitsPlus.replace(/\+/g, '');
|
||||
return `tel:${normalized}`;
|
||||
}
|
||||
|
||||
export function formatDateTime(iso) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString('de-DE');
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDeSyncDateTime(iso) {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
export function extrasName(m) {
|
||||
const x = m?.extras;
|
||||
if (!x || typeof x !== 'object') return '';
|
||||
return String(x.Name || '').trim();
|
||||
}
|
||||
|
||||
/** Anzeige Dauer aus gespeicherten Sekunden (TeamViewer start/end). */
|
||||
export function formatRemoteDurationDe(totalSec) {
|
||||
if (totalSec == null || totalSec < 0) return '';
|
||||
const n = Math.floor(Number(totalSec));
|
||||
const m = Math.floor(n / 60);
|
||||
const s = n % 60;
|
||||
if (m === 0) return `${s} Sek.`;
|
||||
if (s === 0) return `${m} Min.`;
|
||||
return `${m} Min. ${s} Sek.`;
|
||||
}
|
||||
32
public/js/pages/bootstrap.js
vendored
Normal file
32
public/js/pages/bootstrap.js
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
import { apiPost, authFetchStatus } from '../api.js';
|
||||
|
||||
const loadingEl = document.getElementById('page-loading');
|
||||
const panel = document.getElementById('bootstrap-panel');
|
||||
|
||||
async function init() {
|
||||
let st;
|
||||
try {
|
||||
st = await authFetchStatus();
|
||||
} catch {
|
||||
st = { needsBootstrap: false, loggedIn: false };
|
||||
}
|
||||
if (!st.needsBootstrap) {
|
||||
location.href = st.loggedIn ? '/start.html' : '/login.html';
|
||||
return;
|
||||
}
|
||||
|
||||
loadingEl.hidden = true;
|
||||
panel.hidden = false;
|
||||
|
||||
document.getElementById('form-boot').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
await apiPost('/auth/bootstrap', {
|
||||
username: fd.get('username'),
|
||||
password: fd.get('password'),
|
||||
});
|
||||
location.href = '/start.html';
|
||||
};
|
||||
}
|
||||
|
||||
init();
|
||||
44
public/js/pages/login.js
Normal file
44
public/js/pages/login.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { apiPost, authFetchStatus } from '../api.js';
|
||||
|
||||
const loadingEl = document.getElementById('page-loading');
|
||||
const panel = document.getElementById('login-panel');
|
||||
|
||||
async function init() {
|
||||
let st;
|
||||
try {
|
||||
st = await authFetchStatus();
|
||||
} catch {
|
||||
st = { needsBootstrap: false, loggedIn: false };
|
||||
}
|
||||
if (st.needsBootstrap) {
|
||||
location.href = '/bootstrap.html';
|
||||
return;
|
||||
}
|
||||
if (st.loggedIn) {
|
||||
location.href = '/start.html';
|
||||
return;
|
||||
}
|
||||
|
||||
loadingEl.hidden = true;
|
||||
panel.hidden = false;
|
||||
|
||||
document.getElementById('form-login').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
try {
|
||||
await apiPost('/auth/login', {
|
||||
username: fd.get('username'),
|
||||
password: fd.get('password'),
|
||||
});
|
||||
location.href = '/start.html';
|
||||
} catch (err) {
|
||||
document.querySelector('.auth-err')?.remove();
|
||||
const p = document.createElement('p');
|
||||
p.className = 'error auth-err';
|
||||
p.textContent = err.message || 'Anmeldung fehlgeschlagen';
|
||||
document.getElementById('form-login').prepend(p);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
init();
|
||||
170
public/js/pages/machine-detail.js
Normal file
170
public/js/pages/machine-detail.js
Normal file
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
apiDelete,
|
||||
apiGet,
|
||||
apiPost,
|
||||
apiPut,
|
||||
isAuthRedirectError,
|
||||
} from '../api.js';
|
||||
import { guard } from '../core/auth-guard.js';
|
||||
import {
|
||||
collectExtrasFromMachineForm,
|
||||
extrasTableHtml,
|
||||
} from '../core/machine-extras.js';
|
||||
import { extrasName } from '../core/utils.js';
|
||||
|
||||
const UUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
const loadingEl = document.getElementById('page-loading');
|
||||
const badIdEl = document.getElementById('machine-bad-id');
|
||||
const errEl = document.getElementById('page-error');
|
||||
const mainEl = document.getElementById('page-main');
|
||||
const panelView = document.getElementById('panel-machine-view');
|
||||
const formM = document.getElementById('form-m');
|
||||
|
||||
function showError(msg) {
|
||||
loadingEl.hidden = true;
|
||||
badIdEl.hidden = true;
|
||||
mainEl.hidden = true;
|
||||
errEl.hidden = false;
|
||||
errEl.textContent = msg;
|
||||
}
|
||||
|
||||
function fillView(m) {
|
||||
document.getElementById('machine-title').textContent = m.name;
|
||||
document.getElementById('m-typ').textContent = m.typ;
|
||||
document.getElementById('m-serial').textContent = m.seriennummer;
|
||||
document.getElementById('m-standort').textContent = m.standort;
|
||||
const ort = extrasName(m);
|
||||
const row = document.getElementById('m-extras-name-row');
|
||||
if (ort) {
|
||||
row.hidden = false;
|
||||
document.getElementById('m-extras-name').textContent = ort;
|
||||
} else {
|
||||
row.hidden = true;
|
||||
}
|
||||
const tid = `/tickets.html?machineId=${encodeURIComponent(m.id)}`;
|
||||
document.getElementById('m-link-tickets').href = tid;
|
||||
document.getElementById('m-link-tickets-edit').href = tid;
|
||||
document.getElementById('machine-extras-view').innerHTML = extrasTableHtml(m.extras);
|
||||
}
|
||||
|
||||
function fillEdit(m) {
|
||||
document.getElementById('input-m-name').value = m.name;
|
||||
document.getElementById('input-m-typ').value = m.typ;
|
||||
document.getElementById('input-m-serial').value = m.seriennummer;
|
||||
document.getElementById('input-m-standort').value = m.standort;
|
||||
document.getElementById('input-m-list-status').value = m.listStatus || '';
|
||||
document.getElementById('machine-extras-edit').innerHTML = extrasTableHtml(m.extras, {
|
||||
editable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function showViewMode() {
|
||||
panelView.hidden = false;
|
||||
formM.hidden = true;
|
||||
}
|
||||
|
||||
function showEditMode() {
|
||||
panelView.hidden = true;
|
||||
formM.hidden = false;
|
||||
}
|
||||
|
||||
async function viewMachineDetail(id, options = {}) {
|
||||
const { startInEditMode = false } = options;
|
||||
const m = await apiGet(`/machines/${id}`);
|
||||
|
||||
fillView(m);
|
||||
if (startInEditMode) {
|
||||
fillEdit(m);
|
||||
showEditMode();
|
||||
} else {
|
||||
document.getElementById('machine-extras-edit').innerHTML = '';
|
||||
showViewMode();
|
||||
}
|
||||
|
||||
document.getElementById('btn-m-del').onclick = async () => {
|
||||
if (!confirm('Maschine wirklich löschen?')) return;
|
||||
await apiDelete(`/machines/${id}`);
|
||||
location.href = '/machines.html';
|
||||
};
|
||||
|
||||
document.getElementById('btn-m-edit').onclick = () => {
|
||||
fillEdit(m);
|
||||
showEditMode();
|
||||
};
|
||||
|
||||
document.getElementById('btn-m-dup').onclick = async () => {
|
||||
const body = {
|
||||
name: `${m.name} (Kopie)`,
|
||||
typ: m.typ,
|
||||
seriennummer: `${m.seriennummer} - Kopie`,
|
||||
standort: m.standort,
|
||||
listStatus: m.listStatus || '',
|
||||
};
|
||||
if (m.extras && typeof m.extras === 'object') {
|
||||
body.extras = JSON.parse(JSON.stringify(m.extras));
|
||||
}
|
||||
try {
|
||||
const created = await apiPost('/machines', body);
|
||||
location.href = `/machine.html?id=${encodeURIComponent(created.id)}&edit=1`;
|
||||
} catch (err) {
|
||||
alert(err.message || 'Duplizieren fehlgeschlagen.');
|
||||
}
|
||||
};
|
||||
|
||||
formM.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
name: formM.elements.name.value,
|
||||
typ: formM.elements.typ.value,
|
||||
seriennummer: formM.elements.seriennummer.value,
|
||||
standort: formM.elements.standort.value,
|
||||
listStatus: formM.elements.listStatus.value || '',
|
||||
extras: collectExtrasFromMachineForm(formM, m),
|
||||
};
|
||||
await apiPut(`/machines/${id}`, body);
|
||||
location.reload();
|
||||
};
|
||||
|
||||
document.getElementById('m-cancel').onclick = () => {
|
||||
fillView(m);
|
||||
document.getElementById('machine-extras-edit').innerHTML = '';
|
||||
showViewMode();
|
||||
};
|
||||
|
||||
document.getElementById('btn-m-del-edit').onclick = async () => {
|
||||
if (!confirm('Maschine wirklich löschen?')) return;
|
||||
await apiDelete(`/machines/${id}`);
|
||||
location.href = '/machines.html';
|
||||
};
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const st = await guard({ activeNav: 'machines' });
|
||||
if (!st) return;
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const id = params.get('id');
|
||||
const startInEditMode = params.get('edit') === '1';
|
||||
if (!id || !UUID.test(id)) {
|
||||
loadingEl.hidden = true;
|
||||
badIdEl.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loadingEl.hidden = true;
|
||||
mainEl.hidden = false;
|
||||
|
||||
try {
|
||||
await viewMachineDetail(id, { startInEditMode });
|
||||
if (startInEditMode) {
|
||||
history.replaceState(null, '', `/machine.html?id=${encodeURIComponent(id)}`);
|
||||
}
|
||||
} catch (e) {
|
||||
if (isAuthRedirectError(e)) return;
|
||||
showError(e.message || 'Fehler');
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
108
public/js/pages/machines.js
Normal file
108
public/js/pages/machines.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import { apiGet, apiPost, isAuthRedirectError } from '../api.js';
|
||||
import { machineListStatusRowClass } from '../core/constants.js';
|
||||
import { guard } from '../core/auth-guard.js';
|
||||
import { esc } from '../core/utils.js';
|
||||
|
||||
function rowClassForListStatus(listStatus) {
|
||||
const code =
|
||||
listStatus && machineListStatusRowClass[listStatus] !== undefined
|
||||
? listStatus
|
||||
: '';
|
||||
return machineListStatusRowClass[code];
|
||||
}
|
||||
|
||||
const loadingEl = document.getElementById('page-loading');
|
||||
const mainEl = document.getElementById('page-main');
|
||||
const errEl = document.getElementById('page-error');
|
||||
|
||||
function showError(msg) {
|
||||
loadingEl.hidden = true;
|
||||
mainEl.hidden = true;
|
||||
errEl.hidden = false;
|
||||
errEl.textContent = msg;
|
||||
}
|
||||
|
||||
function renderRows(machines) {
|
||||
const tbody = document.getElementById('machine-table-body');
|
||||
tbody.innerHTML = machines
|
||||
.map((m) => {
|
||||
const x = m.extras || {};
|
||||
const rc = rowClassForListStatus(m.listStatus);
|
||||
return `<tr class="${esc(rc)}">
|
||||
<td><a href="/machine.html?id=${esc(m.id)}">${esc(m.seriennummer)}</a></td>
|
||||
<td>${esc(m.typ)}</td>
|
||||
<td>${esc(x.Konzern || '')}</td>
|
||||
<td>${esc(x.Name || '')}</td>
|
||||
<td>${esc(x.Stadt || '')}</td>
|
||||
<td>${esc(x.Land || '')}</td>
|
||||
<td>${esc(x.Jahr || '')}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function initNewMachineCollapse() {
|
||||
const body = document.getElementById('new-machine-section-body');
|
||||
const toggle = document.getElementById('new-machine-toggle');
|
||||
const chev = toggle.querySelector('.ldap-chevron');
|
||||
function setOpen(open) {
|
||||
body.hidden = !open;
|
||||
toggle.setAttribute('aria-expanded', String(open));
|
||||
chev.textContent = open ? '▲' : '▼';
|
||||
}
|
||||
toggle.onclick = () => setOpen(body.hidden);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const machines = await apiGet('/machines');
|
||||
document.getElementById('machine-count').textContent = String(machines.length);
|
||||
renderRows(machines);
|
||||
|
||||
initNewMachineCollapse();
|
||||
|
||||
const formNew = document.getElementById('form-new-machine');
|
||||
formNew.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(formNew);
|
||||
const btn = formNew.querySelector('button[type="submit"]');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const body = Object.fromEntries(fd.entries());
|
||||
const created = await apiPost('/machines', {
|
||||
name: body.name,
|
||||
typ: body.typ,
|
||||
seriennummer: body.seriennummer,
|
||||
standort: body.standort,
|
||||
listStatus: body.listStatus || '',
|
||||
});
|
||||
location.href = `/machine.html?id=${encodeURIComponent(created.id)}`;
|
||||
} catch (err) {
|
||||
alert(err.message || 'Anlegen fehlgeschlagen.');
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const inp = document.getElementById('machine-filter');
|
||||
const tbody = document.getElementById('machine-table-body');
|
||||
inp.addEventListener('input', () => {
|
||||
const q = inp.value.toLowerCase().trim();
|
||||
tbody.querySelectorAll('tr').forEach((tr) => {
|
||||
tr.hidden = !!(q && !tr.textContent.toLowerCase().includes(q));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const st = await guard({ activeNav: 'machines' });
|
||||
if (!st) return;
|
||||
loadingEl.hidden = true;
|
||||
mainEl.hidden = false;
|
||||
try {
|
||||
await run();
|
||||
} catch (e) {
|
||||
if (isAuthRedirectError(e)) return;
|
||||
showError(e.message || 'Fehler');
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
137
public/js/pages/options.js
Normal file
137
public/js/pages/options.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import { apiGet, apiPost, apiPut, isAuthRedirectError } from '../api.js';
|
||||
import { guard } from '../core/auth-guard.js';
|
||||
import { esc, formatDeSyncDateTime } from '../core/utils.js';
|
||||
|
||||
const loadingEl = document.getElementById('page-loading');
|
||||
const mainEl = document.getElementById('page-main');
|
||||
const errEl = document.getElementById('page-error');
|
||||
|
||||
function showError(msg) {
|
||||
loadingEl.hidden = true;
|
||||
mainEl.hidden = true;
|
||||
errEl.hidden = false;
|
||||
errEl.textContent = msg;
|
||||
}
|
||||
|
||||
function renderSyncLog(entries) {
|
||||
const tbody = document.getElementById('sync-log-body');
|
||||
if (!entries.length) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="5" class="muted">Noch keine Einträge.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = entries
|
||||
.map(
|
||||
(e) => `
|
||||
<tr>
|
||||
<td>${esc(formatDeSyncDateTime(e.finishedAt))}</td>
|
||||
<td>${e.triggerType === 'automatic' ? 'Automatisch' : 'Manuell'}</td>
|
||||
<td><span class="sync-status-badge ${e.status === 'success' ? 'sync-status-ok' : 'sync-status-err'}">${e.status === 'success' ? 'Erfolg' : 'Fehler'}</span></td>
|
||||
<td class="num">${esc(String(e.usersSynced ?? 0))}</td>
|
||||
<td>${e.errorMessage ? esc(e.errorMessage) : '—'}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function applyIntegrationForm(data) {
|
||||
const ldap = data.ldap || {};
|
||||
document.getElementById('ldap_syncEnabled').checked = Boolean(ldap.syncEnabled);
|
||||
document.getElementById('ldap_serverUrl').value = ldap.serverUrl ?? '';
|
||||
document.getElementById('ldap_searchBase').value = ldap.searchBase ?? '';
|
||||
document.getElementById('ldap_bindDn').value = ldap.bindDn ?? '';
|
||||
document.getElementById('ldap_bindPassword').value = '';
|
||||
document.getElementById('ldap_userSearchFilter').value =
|
||||
ldap.userSearchFilter || ldap.userFilter || '';
|
||||
document.getElementById('ldap_usernameAttribute').value =
|
||||
ldap.usernameAttribute ?? '';
|
||||
document.getElementById('ldap_firstNameAttribute').value =
|
||||
ldap.firstNameAttribute ?? '';
|
||||
document.getElementById('ldap_lastNameAttribute').value =
|
||||
ldap.lastNameAttribute ?? '';
|
||||
document.getElementById('ldap_syncIntervalMinutes').value = String(
|
||||
ldap.syncIntervalMinutes ?? 1440,
|
||||
);
|
||||
|
||||
const tv = data.teamviewer || {};
|
||||
document.getElementById('tv_bearerToken').value =
|
||||
tv.bearerToken || tv.apiToken || '';
|
||||
document.getElementById('tv_notes').value = tv.notes ?? tv.apiNotes ?? '';
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const data = await apiGet('/settings/integrations');
|
||||
let syncStatus = { lastSyncAt: null, entries: [] };
|
||||
try {
|
||||
syncStatus = await apiGet('/ldap/sync-status');
|
||||
} catch {
|
||||
/* ältere Server */
|
||||
}
|
||||
|
||||
applyIntegrationForm(data);
|
||||
document.getElementById('ldap-last-sync').textContent =
|
||||
`Letzte Synchronisation: ${formatDeSyncDateTime(syncStatus.lastSyncAt)}`;
|
||||
renderSyncLog(Array.isArray(syncStatus.entries) ? syncStatus.entries : []);
|
||||
|
||||
const ldapBody = document.getElementById('ldap-section-body');
|
||||
const ldapToggle = document.getElementById('ldap-toggle');
|
||||
const chev = ldapToggle.querySelector('.ldap-chevron');
|
||||
function setLdapOpen(open) {
|
||||
ldapBody.hidden = !open;
|
||||
ldapToggle.setAttribute('aria-expanded', String(open));
|
||||
chev.textContent = open ? '▲' : '▼';
|
||||
}
|
||||
ldapToggle.onclick = () => setLdapOpen(ldapBody.hidden);
|
||||
|
||||
document.getElementById('btn-ldap-sync-now').onclick = async () => {
|
||||
const btn = document.getElementById('btn-ldap-sync-now');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await apiPost('/ldap/sync', {});
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
alert(err.message || String(err));
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('form-opt').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
await apiPut('/settings/integrations', {
|
||||
ldap: {
|
||||
serverUrl: fd.get('ldap_serverUrl'),
|
||||
bindDn: fd.get('ldap_bindDn'),
|
||||
bindPassword: fd.get('ldap_bindPassword'),
|
||||
searchBase: fd.get('ldap_searchBase'),
|
||||
userSearchFilter: fd.get('ldap_userSearchFilter'),
|
||||
usernameAttribute: fd.get('ldap_usernameAttribute'),
|
||||
firstNameAttribute: fd.get('ldap_firstNameAttribute'),
|
||||
lastNameAttribute: fd.get('ldap_lastNameAttribute'),
|
||||
syncIntervalMinutes: fd.get('ldap_syncIntervalMinutes'),
|
||||
syncEnabled: fd.get('ldap_syncEnabled') === 'on',
|
||||
},
|
||||
teamviewer: {
|
||||
bearerToken: fd.get('tv_bearerToken'),
|
||||
notes: fd.get('tv_notes'),
|
||||
},
|
||||
});
|
||||
location.reload();
|
||||
};
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const st = await guard({ needsAdmin: true, activeNav: 'options' });
|
||||
if (!st) return;
|
||||
loadingEl.hidden = true;
|
||||
mainEl.hidden = false;
|
||||
try {
|
||||
await run();
|
||||
} catch (e) {
|
||||
if (isAuthRedirectError(e)) return;
|
||||
showError(e.message || 'Fehler');
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
157
public/js/pages/start.js
Normal file
157
public/js/pages/start.js
Normal file
@@ -0,0 +1,157 @@
|
||||
import { apiGet, isAuthRedirectError } from '../api.js';
|
||||
import { guard } from '../core/auth-guard.js';
|
||||
import {
|
||||
ticketStatusLabel,
|
||||
ticketPriorityLabel,
|
||||
eventTypeLabel,
|
||||
eventTypeBadgeClass,
|
||||
statusBadgeClass,
|
||||
priorityBadgeClass,
|
||||
} from '../core/constants.js';
|
||||
import { esc, formatDateTime, extrasName } from '../core/utils.js';
|
||||
import { bindAttachmentPreview } from '../core/attachment-preview.js';
|
||||
import {
|
||||
eventInhaltHtml,
|
||||
sortEventsChronologicalWithAttachmentsLast,
|
||||
} from '../core/ticket-events.js';
|
||||
|
||||
const loadingEl = document.getElementById('page-loading');
|
||||
const mainEl = document.getElementById('page-main');
|
||||
const errEl = document.getElementById('page-error');
|
||||
const listEl = document.getElementById('home-ticket-list');
|
||||
const emptyEl = document.getElementById('home-empty');
|
||||
const tpl = document.getElementById('tpl-home-ticket');
|
||||
|
||||
function showError(msg) {
|
||||
loadingEl.hidden = true;
|
||||
mainEl.hidden = true;
|
||||
errEl.hidden = false;
|
||||
errEl.textContent = msg;
|
||||
}
|
||||
|
||||
function renderEventBoxes(events) {
|
||||
const evChrono = sortEventsChronologicalWithAttachmentsLast(events);
|
||||
if (evChrono.length === 0) {
|
||||
return '<p class="muted home-ticket-no-events">Noch keine Ereignisse in der Historie.</p>';
|
||||
}
|
||||
return evChrono
|
||||
.map(
|
||||
(ev) => `
|
||||
<div class="home-event-box">
|
||||
<div class="home-event-box-header">
|
||||
<span class="badge event-type-badge ${eventTypeBadgeClass[ev.type] || ''}">${esc(eventTypeLabel[ev.type] || ev.type)}</span>
|
||||
<time class="home-event-box-time" datetime="${esc(ev.createdAt)}">${esc(formatDateTime(ev.createdAt))}</time>
|
||||
</div>
|
||||
<div class="home-event-box-body">${eventInhaltHtml(ev)}</div>
|
||||
</div>`,
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function fillTicketCard(node, t, events) {
|
||||
const id = t.id;
|
||||
const detailId = `home-ticket-detail-${id}`;
|
||||
node.dataset.ticketId = id;
|
||||
|
||||
const btn = node.querySelector('.home-ticket-collapse-btn');
|
||||
const panel = node.querySelector('.home-ticket-collapsible');
|
||||
panel.id = detailId;
|
||||
btn.setAttribute('aria-controls', detailId);
|
||||
|
||||
const mn = t.machine ? extrasName(t.machine) : '';
|
||||
const machineLabel = t.machine
|
||||
? `${esc(t.machine.seriennummer)}${mn ? ` · ${esc(mn)}` : ''}`
|
||||
: '';
|
||||
const standort = t.machine ? esc(t.machine.standort) : '—';
|
||||
|
||||
const headEl = node.querySelector('.home-ticket-head');
|
||||
headEl.classList.toggle('home-ticket-head-overdue', Boolean(t.isOverdue));
|
||||
|
||||
const titleA = node.querySelector('.js-ticket-link');
|
||||
titleA.href = `/ticket.html?id=${encodeURIComponent(id)}`;
|
||||
titleA.textContent = t.title;
|
||||
|
||||
const st = node.querySelector('.js-status');
|
||||
st.textContent = ticketStatusLabel[t.status];
|
||||
st.className = `badge js-status ${statusBadgeClass[t.status] || ''}`;
|
||||
|
||||
const pr = node.querySelector('.js-priority');
|
||||
pr.textContent = ticketPriorityLabel[t.priority];
|
||||
pr.className = `badge js-priority ${priorityBadgeClass[t.priority] || ''}`;
|
||||
|
||||
const metaM = node.querySelector('.js-meta-machine');
|
||||
metaM.innerHTML = t.machine
|
||||
? `<span class="muted">Maschine:</span> ${machineLabel}`
|
||||
: '<span class="muted">Keine Maschine</span>';
|
||||
|
||||
node.querySelector('.js-meta-standort').textContent = standort;
|
||||
node.querySelector('.js-meta-created').textContent = formatDateTime(t.createdAt);
|
||||
node.querySelector('.js-meta-updated').textContent = formatDateTime(t.updatedAt);
|
||||
|
||||
const openA = node.querySelector('.js-ticket-open');
|
||||
openA.href = `/ticket.html?id=${encodeURIComponent(id)}`;
|
||||
|
||||
node.querySelector('.js-desc').textContent = t.description;
|
||||
|
||||
const mBlock = node.querySelector('.js-machine-block');
|
||||
if (t.machine) {
|
||||
const m = t.machine;
|
||||
mBlock.innerHTML = `<p class="home-ticket-machine"><strong>Maschine:</strong> <a href="/machine.html?id=${esc(m.id)}">${esc(m.seriennummer)}</a>${mn ? ` · ${esc(mn)}` : ''} · <strong>Typ:</strong> ${esc(m.typ)}</p>`;
|
||||
} else {
|
||||
mBlock.innerHTML = '';
|
||||
}
|
||||
|
||||
node.querySelector('.js-events').innerHTML = renderEventBoxes(events);
|
||||
|
||||
const chev = btn.querySelector('.home-ticket-chevron');
|
||||
btn.onclick = () => {
|
||||
const willShow = panel.hidden;
|
||||
panel.hidden = !willShow;
|
||||
btn.setAttribute('aria-expanded', String(willShow));
|
||||
chev.textContent = willShow ? '▲' : '▼';
|
||||
};
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const tickets = await apiGet('/tickets?open=1');
|
||||
const eventsLists =
|
||||
tickets.length === 0
|
||||
? []
|
||||
: await Promise.all(tickets.map((t) => apiGet(`/tickets/${t.id}/events`)));
|
||||
|
||||
const openCount = tickets.filter((t) => t.status === 'OPEN').length;
|
||||
const waitingCount = tickets.filter((t) => t.status === 'WAITING').length;
|
||||
|
||||
document.getElementById('kpi-open').textContent = `${openCount} Offen`;
|
||||
document.getElementById('kpi-waiting').textContent = `${waitingCount} Wartend`;
|
||||
document.getElementById('kpi-total').textContent = `gesamt: ${tickets.length}`;
|
||||
|
||||
listEl.innerHTML = '';
|
||||
if (tickets.length === 0) {
|
||||
emptyEl.hidden = false;
|
||||
} else {
|
||||
emptyEl.hidden = true;
|
||||
tickets.forEach((t, i) => {
|
||||
const frag = tpl.content.cloneNode(true);
|
||||
const article = frag.querySelector('.home-ticket-card');
|
||||
fillTicketCard(article, t, eventsLists[i] || []);
|
||||
listEl.appendChild(article);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const st = await guard({ activeNav: 'start' });
|
||||
if (!st) return;
|
||||
loadingEl.hidden = true;
|
||||
mainEl.hidden = false;
|
||||
bindAttachmentPreview(document.body);
|
||||
try {
|
||||
await run();
|
||||
} catch (e) {
|
||||
if (isAuthRedirectError(e)) return;
|
||||
showError(e.message || 'Fehler');
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
283
public/js/pages/ticket-detail.js
Normal file
283
public/js/pages/ticket-detail.js
Normal file
@@ -0,0 +1,283 @@
|
||||
import { apiGet, apiPost, apiPostForm, apiPut, isAuthRedirectError } from '../api.js';
|
||||
import { guard } from '../core/auth-guard.js';
|
||||
import {
|
||||
ticketStatusLabel,
|
||||
ticketPriorityLabel,
|
||||
eventTypeLabel,
|
||||
eventTypeBadgeClass,
|
||||
statusBadgeClass,
|
||||
priorityBadgeClass,
|
||||
} from '../core/constants.js';
|
||||
import { esc, formatDateTime, extrasName } from '../core/utils.js';
|
||||
import { bindAttachmentPreview } from '../core/attachment-preview.js';
|
||||
import {
|
||||
eventInhaltHtml,
|
||||
syncEventFormFieldGroups,
|
||||
buildEventPostBody,
|
||||
loadTeamViewerConnectionsIntoSelect,
|
||||
sortEventsChronologicalWithAttachmentsLast,
|
||||
} from '../core/ticket-events.js';
|
||||
|
||||
const UUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
const loadingEl = document.getElementById('page-loading');
|
||||
const badIdEl = document.getElementById('ticket-bad-id');
|
||||
const errEl = document.getElementById('page-error');
|
||||
const mainEl = document.getElementById('page-main');
|
||||
const panelView = document.getElementById('panel-ticket-view');
|
||||
const panelEdit = document.getElementById('panel-ticket-edit');
|
||||
|
||||
function showError(msg) {
|
||||
loadingEl.hidden = true;
|
||||
badIdEl.hidden = true;
|
||||
mainEl.hidden = true;
|
||||
errEl.hidden = false;
|
||||
errEl.textContent = msg;
|
||||
}
|
||||
|
||||
function fillTicketView(ticket) {
|
||||
document.getElementById('ticket-title').textContent = ticket.title;
|
||||
const stBadge = document.getElementById('t-status-badge');
|
||||
stBadge.textContent = ticketStatusLabel[ticket.status];
|
||||
stBadge.className = `badge ${statusBadgeClass[ticket.status] || ''}`;
|
||||
document.getElementById('t-priority-label').textContent =
|
||||
ticketPriorityLabel[ticket.priority];
|
||||
const slaSel = document.getElementById('t-sla-days');
|
||||
if (slaSel) {
|
||||
slaSel.value =
|
||||
ticket.slaDays != null && ticket.slaDays !== ''
|
||||
? String(ticket.slaDays)
|
||||
: '';
|
||||
}
|
||||
document.getElementById('t-description').textContent = ticket.description;
|
||||
|
||||
const mrow = document.getElementById('t-machine-row');
|
||||
if (ticket.machine) {
|
||||
mrow.hidden = false;
|
||||
const mn = extrasName(ticket.machine);
|
||||
const link = document.getElementById('t-machine-link');
|
||||
link.href = `/machine.html?id=${encodeURIComponent(ticket.machine.id)}`;
|
||||
link.textContent = ticket.machine.seriennummer;
|
||||
document.getElementById('t-machine-suffix').textContent = mn
|
||||
? ` · ${mn}`
|
||||
: '';
|
||||
} else {
|
||||
mrow.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function fillEditForm(ticket) {
|
||||
document.getElementById('tu-title').value = ticket.title;
|
||||
document.getElementById('tu-desc').value = ticket.description;
|
||||
document.getElementById('tu-status').value = ticket.status;
|
||||
document.getElementById('tu-priority').value = ticket.priority;
|
||||
}
|
||||
|
||||
function renderEvents(events) {
|
||||
const tbody = document.getElementById('events-table-body');
|
||||
if (events.length === 0) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="3" class="muted">Noch keine Ereignisse.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = events
|
||||
.map(
|
||||
(ev) => `
|
||||
<tr>
|
||||
<td class="events-table-time">${esc(formatDateTime(ev.createdAt))}</td>
|
||||
<td><span class="badge event-type-badge ${eventTypeBadgeClass[ev.type] || ''}">${esc(eventTypeLabel[ev.type] || ev.type)}</span></td>
|
||||
<td class="events-table-desc">${eventInhaltHtml(ev)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function showViewMode() {
|
||||
panelView.hidden = false;
|
||||
panelEdit.hidden = true;
|
||||
}
|
||||
|
||||
function showEditMode() {
|
||||
panelView.hidden = true;
|
||||
panelEdit.hidden = false;
|
||||
}
|
||||
|
||||
async function viewTicketDetail(id) {
|
||||
const [ticket, events] = await Promise.all([
|
||||
apiGet(`/tickets/${id}`),
|
||||
apiGet(`/tickets/${id}/events`),
|
||||
]);
|
||||
|
||||
let currentTicket = ticket;
|
||||
|
||||
fillTicketView(currentTicket);
|
||||
fillEditForm(currentTicket);
|
||||
showViewMode();
|
||||
renderEvents(sortEventsChronologicalWithAttachmentsLast(events));
|
||||
|
||||
const sect2 = document.getElementById('sect-second-ticket');
|
||||
if (currentTicket.machineId) {
|
||||
sect2.hidden = false;
|
||||
} else {
|
||||
sect2.hidden = true;
|
||||
}
|
||||
|
||||
const slaSel = document.getElementById('t-sla-days');
|
||||
if (slaSel) {
|
||||
slaSel.onchange = async () => {
|
||||
const v = slaSel.value;
|
||||
const slaDays = v === '' ? null : Number(v);
|
||||
try {
|
||||
const updated = await apiPut(`/tickets/${id}`, { slaDays });
|
||||
const evs = await apiGet(`/tickets/${id}/events`);
|
||||
currentTicket = updated;
|
||||
fillTicketView(updated);
|
||||
fillEditForm(updated);
|
||||
renderEvents(sortEventsChronologicalWithAttachmentsLast(evs));
|
||||
} catch (err) {
|
||||
slaSel.value =
|
||||
currentTicket.slaDays != null
|
||||
? String(currentTicket.slaDays)
|
||||
: '';
|
||||
if (isAuthRedirectError(err)) return;
|
||||
window.alert(err.message || 'Fehler beim Speichern der Fälligkeit.');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
document.getElementById('btn-t-edit').onclick = () => {
|
||||
fillEditForm(currentTicket);
|
||||
showEditMode();
|
||||
};
|
||||
|
||||
panelEdit.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(panelEdit);
|
||||
await apiPut(`/tickets/${id}`, Object.fromEntries(fd.entries()));
|
||||
location.reload();
|
||||
};
|
||||
|
||||
document.getElementById('tu-cancel').onclick = () => {
|
||||
fillTicketView(currentTicket);
|
||||
showViewMode();
|
||||
};
|
||||
|
||||
const formEv = document.getElementById('form-ev');
|
||||
syncEventFormFieldGroups(formEv);
|
||||
formEv.querySelector('#ev-type-sel').onchange = () =>
|
||||
syncEventFormFieldGroups(formEv);
|
||||
document.getElementById('btn-tv-reload').onclick = () =>
|
||||
loadTeamViewerConnectionsIntoSelect();
|
||||
formEv.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const evType = fd.get('type');
|
||||
if (evType === 'ATTACHMENT') {
|
||||
const fileInput = document.getElementById('ev-attachment-files');
|
||||
const files = fileInput?.files;
|
||||
if (!files || files.length === 0) {
|
||||
formEv.insertAdjacentHTML(
|
||||
'afterbegin',
|
||||
'<p class="error tv-form-err">Mindestens eine Datei auswählen.</p>',
|
||||
);
|
||||
setTimeout(() => {
|
||||
document.querySelector('.tv-form-err')?.remove();
|
||||
}, 4000);
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
'description',
|
||||
String(fd.get('description_attachment') ?? '').trim(),
|
||||
);
|
||||
for (let i = 0; i < files.length; i += 1) {
|
||||
formData.append('files', files[i]);
|
||||
}
|
||||
await apiPostForm(`/tickets/${id}/events/attachments`, formData);
|
||||
e.target.reset();
|
||||
syncEventFormFieldGroups(formEv);
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
let body = buildEventPostBody(id, fd);
|
||||
if (body.type === 'REMOTE') {
|
||||
const sel = document.getElementById('tv-conn-select');
|
||||
const opt = sel?.selectedOptions?.[0];
|
||||
if (opt?.value) {
|
||||
body.teamviewerId = opt.value;
|
||||
const sd = opt.getAttribute('data-start-date');
|
||||
const ed = opt.getAttribute('data-end-date');
|
||||
if (sd) body.teamviewerStartDate = sd;
|
||||
if (ed) body.teamviewerEndDate = ed;
|
||||
const n = opt.getAttribute('data-notes');
|
||||
if (n && String(n).trim()) body.teamviewerNotes = String(n).trim();
|
||||
const dn = opt.getAttribute('data-devicename') || '';
|
||||
const u = String(fd.get('description_remote') ?? '').trim();
|
||||
if (dn) {
|
||||
body.description = u
|
||||
? `${u}\n\nTeamViewer-Gerät: ${dn}`
|
||||
: `TeamViewer-Gerät: ${dn}`;
|
||||
} else if (u) {
|
||||
body.description = u;
|
||||
} else {
|
||||
body.description = 'Remote-Session (TeamViewer)';
|
||||
}
|
||||
} else {
|
||||
body.description = String(body.description ?? '').trim();
|
||||
if (!body.description) {
|
||||
formEv.insertAdjacentHTML(
|
||||
'afterbegin',
|
||||
'<p class="error tv-form-err">Beschreibung oder Gerät auswählen.</p>',
|
||||
);
|
||||
setTimeout(() => {
|
||||
document.querySelector('.tv-form-err')?.remove();
|
||||
}, 4000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
await apiPost('/events', body);
|
||||
e.target.reset();
|
||||
syncEventFormFieldGroups(formEv);
|
||||
location.reload();
|
||||
};
|
||||
|
||||
document.getElementById('form-t2').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
await apiPost('/tickets', {
|
||||
machineId: currentTicket.machineId,
|
||||
title: fd.get('title'),
|
||||
description: fd.get('description'),
|
||||
});
|
||||
e.target.reset();
|
||||
location.reload();
|
||||
};
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const st = await guard({ activeNav: 'tickets' });
|
||||
if (!st) return;
|
||||
|
||||
const id = new URLSearchParams(location.search).get('id');
|
||||
if (!id || !UUID.test(id)) {
|
||||
loadingEl.hidden = true;
|
||||
badIdEl.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loadingEl.hidden = true;
|
||||
mainEl.hidden = false;
|
||||
|
||||
bindAttachmentPreview(document.body);
|
||||
|
||||
try {
|
||||
await viewTicketDetail(id);
|
||||
} catch (e) {
|
||||
if (isAuthRedirectError(e)) return;
|
||||
showError(e.message || 'Fehler');
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
122
public/js/pages/tickets.js
Normal file
122
public/js/pages/tickets.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import { apiGet, apiPost, isAuthRedirectError } from '../api.js';
|
||||
import { guard } from '../core/auth-guard.js';
|
||||
import {
|
||||
ticketStatusLabel,
|
||||
ticketPriorityLabel,
|
||||
statusBadgeClass,
|
||||
priorityBadgeClass,
|
||||
} from '../core/constants.js';
|
||||
import { esc, extrasName } from '../core/utils.js';
|
||||
|
||||
const loadingEl = document.getElementById('page-loading');
|
||||
const mainEl = document.getElementById('page-main');
|
||||
const errEl = document.getElementById('page-error');
|
||||
|
||||
function showError(msg) {
|
||||
loadingEl.hidden = true;
|
||||
mainEl.hidden = true;
|
||||
errEl.hidden = false;
|
||||
errEl.textContent = msg;
|
||||
}
|
||||
|
||||
function ticketListQuery() {
|
||||
const q = new URLSearchParams(location.search);
|
||||
const p = new URLSearchParams();
|
||||
if (q.get('status')) p.set('status', q.get('status'));
|
||||
if (q.get('priority')) p.set('priority', q.get('priority'));
|
||||
if (q.get('machineId')) p.set('machineId', q.get('machineId'));
|
||||
const s = p.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
function fillMachineSelects(allMachines, selectedMid) {
|
||||
const selNm = document.getElementById('sel-nm');
|
||||
const selFm = document.getElementById('sel-fm');
|
||||
const opts = allMachines
|
||||
.map(
|
||||
(m) =>
|
||||
`<option value="${esc(m.id)}">${esc(m.seriennummer)}${extrasName(m) ? ` · ${esc(extrasName(m))}` : ''}</option>`,
|
||||
)
|
||||
.join('');
|
||||
selNm.innerHTML = `<option value="">— wählen —</option>${opts}`;
|
||||
selFm.innerHTML = `<option value="">Alle</option>${opts}`;
|
||||
if (selectedMid) {
|
||||
selNm.value = selectedMid;
|
||||
selFm.value = selectedMid;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTicketRows(tickets) {
|
||||
const tbody = document.getElementById('tickets-table-body');
|
||||
tbody.innerHTML = tickets
|
||||
.map(
|
||||
(t) => `
|
||||
<tr${t.isOverdue ? ' class="ticket-row-overdue"' : ''}>
|
||||
<td><a href="/ticket.html?id=${esc(t.id)}">${esc(t.title)}</a></td>
|
||||
<td><span class="badge ${statusBadgeClass[t.status] || ''}">${esc(ticketStatusLabel[t.status])}</span></td>
|
||||
<td><span class="badge ${priorityBadgeClass[t.priority] || ''}">${esc(ticketPriorityLabel[t.priority])}</span></td>
|
||||
<td class="muted">${t.machine ? esc(t.machine.seriennummer) : ''}${t.machine ? (extrasName(t.machine) ? ` · ${esc(extrasName(t.machine))}` : '') : ''}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const qs = ticketListQuery();
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
|
||||
const [tickets, allMachines] = await Promise.all([
|
||||
apiGet(`/tickets${qs}`),
|
||||
apiGet('/machines'),
|
||||
]);
|
||||
|
||||
const mid = urlParams.get('machineId') || '';
|
||||
fillMachineSelects(allMachines, mid);
|
||||
|
||||
const filterStatus = urlParams.get('status') || '';
|
||||
const filterPriority = urlParams.get('priority') || '';
|
||||
const formFilter = document.getElementById('form-filter');
|
||||
formFilter.elements.status.value = filterStatus;
|
||||
formFilter.elements.priority.value = filterPriority;
|
||||
|
||||
renderTicketRows(tickets);
|
||||
|
||||
document.getElementById('form-new-ticket').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
await apiPost('/tickets', {
|
||||
machineId: fd.get('machineId'),
|
||||
title: fd.get('title'),
|
||||
description: fd.get('description'),
|
||||
});
|
||||
e.target.reset();
|
||||
fillMachineSelects(allMachines, mid);
|
||||
location.reload();
|
||||
};
|
||||
|
||||
document.getElementById('form-filter').onsubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const p = new URLSearchParams();
|
||||
if (fd.get('status')) p.set('status', fd.get('status'));
|
||||
if (fd.get('priority')) p.set('priority', fd.get('priority'));
|
||||
if (fd.get('machineId')) p.set('machineId', fd.get('machineId'));
|
||||
const q = p.toString();
|
||||
location.href = `/tickets.html${q ? `?${q}` : ''}`;
|
||||
};
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const st = await guard({ activeNav: 'tickets' });
|
||||
if (!st) return;
|
||||
loadingEl.hidden = true;
|
||||
mainEl.hidden = false;
|
||||
try {
|
||||
await run();
|
||||
} catch (e) {
|
||||
if (isAuthRedirectError(e)) return;
|
||||
showError(e.message || 'Fehler');
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
93
public/js/pages/users.js
Normal file
93
public/js/pages/users.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import { apiDelete, apiGet, apiPost, apiPut, isAuthRedirectError } from '../api.js';
|
||||
import { guard } from '../core/auth-guard.js';
|
||||
import { esc } from '../core/utils.js';
|
||||
|
||||
const loadingEl = document.getElementById('page-loading');
|
||||
const mainEl = document.getElementById('page-main');
|
||||
const errEl = document.getElementById('page-error');
|
||||
|
||||
function showError(msg) {
|
||||
loadingEl.hidden = true;
|
||||
mainEl.hidden = true;
|
||||
errEl.hidden = false;
|
||||
errEl.textContent = msg;
|
||||
}
|
||||
|
||||
function renderRows(users) {
|
||||
const tbody = document.getElementById('users-table-body');
|
||||
tbody.innerHTML = users
|
||||
.map(
|
||||
(u) => `
|
||||
<tr data-id="${esc(u.id)}">
|
||||
<td>${esc(u.username)}</td>
|
||||
<td><span class="badge">${u.role === 'admin' ? 'Admin' : 'Benutzer'}</span></td>
|
||||
<td class="muted">${u.source === 'ldap' ? 'LDAP' : 'Lokal'}</td>
|
||||
<td>${u.active ? 'Ja' : 'Nein'}</td>
|
||||
<td class="users-actions">
|
||||
${u.source === 'local' ? `<button type="button" class="secondary btn-pw" data-id="${esc(u.id)}">Passwort</button>` : ''}
|
||||
<button type="button" class="secondary btn-toggle" data-id="${esc(u.id)}" data-active="${u.active ? '1' : '0'}">${u.active ? 'Deaktivieren' : 'Aktivieren'}</button>
|
||||
<button type="button" class="danger btn-del-user" data-id="${esc(u.id)}">Löschen</button>
|
||||
</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const users = await apiGet('/users');
|
||||
renderRows(users);
|
||||
|
||||
document.getElementById('form-new-user').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
await apiPost('/users', {
|
||||
username: fd.get('username'),
|
||||
password: fd.get('password'),
|
||||
role: fd.get('role'),
|
||||
});
|
||||
e.target.reset();
|
||||
location.reload();
|
||||
};
|
||||
|
||||
const root = document.getElementById('page-main');
|
||||
root.querySelectorAll('.btn-pw').forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
const uid = btn.getAttribute('data-id');
|
||||
const pw = window.prompt('Neues Passwort (min. 8 Zeichen):');
|
||||
if (!pw || pw.length < 8) return;
|
||||
await apiPut(`/users/${uid}`, { password: pw });
|
||||
location.reload();
|
||||
};
|
||||
});
|
||||
root.querySelectorAll('.btn-toggle').forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
const uid = btn.getAttribute('data-id');
|
||||
const active = btn.getAttribute('data-active') === '1';
|
||||
await apiPut(`/users/${uid}`, { active: !active });
|
||||
location.reload();
|
||||
};
|
||||
});
|
||||
root.querySelectorAll('.btn-del-user').forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
if (!window.confirm('Benutzer wirklich löschen?')) return;
|
||||
const uid = btn.getAttribute('data-id');
|
||||
await apiDelete(`/users/${uid}`);
|
||||
location.reload();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const st = await guard({ needsAdmin: true, activeNav: 'users' });
|
||||
if (!st) return;
|
||||
loadingEl.hidden = true;
|
||||
mainEl.hidden = false;
|
||||
try {
|
||||
await run();
|
||||
} catch (e) {
|
||||
if (isAuthRedirectError(e)) return;
|
||||
showError(e.message || 'Fehler');
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
30
public/login.html
Normal file
30
public/login.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Anmelden — SDS CRM</title>
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
<link rel="stylesheet" href="/css/pages/auth.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1><a href="/start.html">SDS CRM</a></h1>
|
||||
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
|
||||
</header>
|
||||
<main id="app" class="main">
|
||||
<p id="page-loading" class="muted">Lade …</p>
|
||||
<div id="login-panel" class="stack auth-panel" hidden>
|
||||
<h2>Anmelden</h2>
|
||||
<div class="card">
|
||||
<form id="form-login" class="stack">
|
||||
<label>Benutzername <input name="username" required autocomplete="username" /></label>
|
||||
<label>Passwort <input name="password" type="password" required autocomplete="current-password" /></label>
|
||||
<button type="submit">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="/js/pages/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
85
public/machine.html
Normal file
85
public/machine.html
Normal file
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Maschine — SDS CRM</title>
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
<link rel="stylesheet" href="/css/pages/detail.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1><a href="/start.html">SDS CRM</a></h1>
|
||||
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
|
||||
</header>
|
||||
<main id="app" class="main">
|
||||
<p id="page-loading" class="muted">Lade …</p>
|
||||
<div id="machine-bad-id" hidden>
|
||||
<p class="error">Ungültige oder fehlende Maschinen-ID.</p>
|
||||
<p><a href="/machines.html">← Zur Übersicht</a></p>
|
||||
</div>
|
||||
<p id="page-error" class="error" hidden></p>
|
||||
<div id="page-main" class="stack" hidden>
|
||||
<p><a href="/machines.html">← Alle Maschinen</a></p>
|
||||
<h2 id="machine-title"></h2>
|
||||
|
||||
<div id="panel-machine-view">
|
||||
<div class="card" id="mach-view">
|
||||
<p><strong>Typ:</strong> <span id="m-typ"></span></p>
|
||||
<p><strong>Seriennummer:</strong> <span id="m-serial"></span></p>
|
||||
<p><strong>Standort:</strong> <span id="m-standort"></span></p>
|
||||
<p id="m-extras-name-row" hidden>
|
||||
<strong>Name (Anlagenliste):</strong> <span id="m-extras-name"></span>
|
||||
</p>
|
||||
<div class="row">
|
||||
<button type="button" id="btn-m-edit">Bearbeiten</button>
|
||||
<button type="button" class="secondary" id="btn-m-dup">Duplizieren</button>
|
||||
<a
|
||||
class="badge"
|
||||
id="m-link-tickets"
|
||||
href="#"
|
||||
style="align-self: center"
|
||||
>Tickets dieser Maschine</a
|
||||
>
|
||||
<button type="button" class="danger" id="btn-m-del">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="machine-extras-view"></div>
|
||||
</div>
|
||||
|
||||
<form id="form-m" class="stack" hidden>
|
||||
<div class="card">
|
||||
<h3 class="machine-detail-card-title">Stammdaten</h3>
|
||||
<div class="row machine-detail-actions">
|
||||
<button type="submit">Speichern</button>
|
||||
<button type="button" class="secondary" id="m-cancel">Abbrechen</button>
|
||||
<a class="badge" id="m-link-tickets-edit" href="#">Tickets dieser Maschine</a>
|
||||
<button type="button" class="danger" id="btn-m-del-edit">Löschen</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Name <input name="name" id="input-m-name" required /></label>
|
||||
<label>Typ <input name="typ" id="input-m-typ" required /></label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Seriennummer <input name="seriennummer" id="input-m-serial" required /></label>
|
||||
<label>Standort <input name="standort" id="input-m-standort" required /></label>
|
||||
</div>
|
||||
<label
|
||||
>Status (Liste)
|
||||
<select name="listStatus" id="input-m-list-status">
|
||||
<option value="">— keiner —</option>
|
||||
<option value="PRUEFEN">Prüfen!</option>
|
||||
<option value="VERSCHROTTET">Verschrottet</option>
|
||||
<option value="SN_GEAENDERT">SN geändert</option>
|
||||
<option value="IN_BEARBEITUNG">In Bearbeitung</option>
|
||||
<option value="UPDATE_RAUS">Update raus</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div id="machine-extras-edit"></div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="/js/pages/machine-detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
132
public/machines.html
Normal file
132
public/machines.html
Normal file
@@ -0,0 +1,132 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Maschinen — SDS CRM</title>
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
<link rel="stylesheet" href="/css/pages/machines.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1><a href="/start.html">SDS CRM</a></h1>
|
||||
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
|
||||
</header>
|
||||
<main id="app" class="main">
|
||||
<p id="page-loading" class="muted">Lade …</p>
|
||||
<p id="page-error" class="error" hidden></p>
|
||||
<div id="page-main" class="stack machines-overview" hidden>
|
||||
<div class="machines-overview-header">
|
||||
<div class="machines-overview-title">
|
||||
<h2>Maschinen (Anlagenliste)</h2>
|
||||
<p class="muted">
|
||||
<span id="machine-count">0</span>
|
||||
</p>
|
||||
</div>
|
||||
<aside class="machine-legend" aria-label="Legende Zeilenfarben">
|
||||
<div class="machine-legend-title">Farben (Listen-Status)</div>
|
||||
<ul class="machine-legend-list">
|
||||
<li>
|
||||
<span class="machine-legend-swatch machine-row--none" aria-hidden="true"></span>
|
||||
<span>kein Status</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="machine-legend-swatch machine-row--pruefen" aria-hidden="true"></span>
|
||||
<span>Prüfen!</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="machine-legend-swatch machine-row--verschrottet" aria-hidden="true"></span>
|
||||
<span>Verschrottet</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="machine-legend-swatch machine-row--sn" aria-hidden="true"></span>
|
||||
<span>SN geändert</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="machine-legend-swatch machine-row--bearbeitung" aria-hidden="true"></span>
|
||||
<span>In Bearbeitung</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="machine-legend-swatch machine-row--update" aria-hidden="true"></span>
|
||||
<span>Update raus</span>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="card">
|
||||
<label
|
||||
>Suche
|
||||
<input
|
||||
type="search"
|
||||
id="machine-filter"
|
||||
placeholder="Seriennummer, Typ, Ort, …"
|
||||
style="min-width: min(100%, 22rem)"
|
||||
/></label>
|
||||
</div>
|
||||
<div class="card table-wrap">
|
||||
<table id="machine-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Seriennr.</th>
|
||||
<th>Typ</th>
|
||||
<th>Konzern</th>
|
||||
<th>Name (Excel)</th>
|
||||
<th>Stadt</th>
|
||||
<th>Land</th>
|
||||
<th>Jahr</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="machine-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card options-section ldap-section machines-new-machine">
|
||||
<button
|
||||
type="button"
|
||||
class="ldap-section-toggle"
|
||||
id="new-machine-toggle"
|
||||
aria-expanded="false"
|
||||
aria-controls="new-machine-section-body"
|
||||
>
|
||||
<span class="ldap-section-heading">Neue Maschine anlegen</span>
|
||||
<span class="ldap-chevron" aria-hidden="true">▼</span>
|
||||
</button>
|
||||
<div class="ldap-section-body" id="new-machine-section-body" hidden>
|
||||
<form id="form-new-machine" class="stack">
|
||||
<div class="row">
|
||||
<label
|
||||
>Name
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
autocomplete="organization"
|
||||
placeholder="z. B. Anlage Standort A"
|
||||
/></label>
|
||||
<label>Typ <input name="typ" required placeholder="Maschinentyp" /></label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label
|
||||
>Seriennummer
|
||||
<input name="seriennummer" required autocomplete="off" placeholder="eindeutig"
|
||||
/></label>
|
||||
<label>Standort <input name="standort" required placeholder="Ort / Adresse" /></label>
|
||||
</div>
|
||||
<label
|
||||
>Status (Liste)
|
||||
<select name="listStatus" id="new-machine-status">
|
||||
<option value="">— keiner —</option>
|
||||
<option value="PRUEFEN">Prüfen!</option>
|
||||
<option value="VERSCHROTTET">Verschrottet</option>
|
||||
<option value="SN_GEAENDERT">SN geändert</option>
|
||||
<option value="IN_BEARBEITUNG">In Bearbeitung</option>
|
||||
<option value="UPDATE_RAUS">Update raus</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Maschine anlegen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="/js/pages/machines.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
157
public/options.html
Normal file
157
public/options.html
Normal file
@@ -0,0 +1,157 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Optionen — SDS CRM</title>
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
<link rel="stylesheet" href="/css/pages/options.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1><a href="/start.html">SDS CRM</a></h1>
|
||||
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
|
||||
</header>
|
||||
<main id="app" class="main">
|
||||
<p id="page-loading" class="muted">Lade …</p>
|
||||
<p id="page-error" class="error" hidden></p>
|
||||
<div id="page-main" class="stack options-page" hidden>
|
||||
<p><a href="/start.html">← Start</a></p>
|
||||
<h2>Optionen</h2>
|
||||
<p class="muted">Integrationen für das CRM. Werte werden in der Datenbank gespeichert.</p>
|
||||
<form id="form-opt" class="stack">
|
||||
<div class="card options-section ldap-section">
|
||||
<button
|
||||
type="button"
|
||||
class="ldap-section-toggle"
|
||||
id="ldap-toggle"
|
||||
aria-expanded="true"
|
||||
aria-controls="ldap-section-body"
|
||||
>
|
||||
<span class="ldap-section-heading">LDAP-Synchronisation</span>
|
||||
<span class="ldap-chevron" aria-hidden="true">▲</span>
|
||||
</button>
|
||||
<div class="ldap-section-body" id="ldap-section-body">
|
||||
<h4 class="ldap-subtitle">LDAP-Konfiguration</h4>
|
||||
<label class="row-inline ldap-sync-check">
|
||||
<input type="checkbox" name="ldap_syncEnabled" id="ldap_syncEnabled" value="on" />
|
||||
LDAP-Synchronisation aktivieren
|
||||
</label>
|
||||
<div class="form-grid-2">
|
||||
<label
|
||||
>LDAP-Server URL
|
||||
<input
|
||||
name="ldap_serverUrl"
|
||||
id="ldap_serverUrl"
|
||||
type="text"
|
||||
placeholder="ldap://…"
|
||||
autocomplete="off"
|
||||
/></label>
|
||||
<label
|
||||
>Base DN
|
||||
<input name="ldap_searchBase" id="ldap_searchBase" placeholder="DC=…" autocomplete="off"
|
||||
/></label>
|
||||
<label>Bind DN (optional) <input name="ldap_bindDn" id="ldap_bindDn" autocomplete="off" /></label>
|
||||
<label
|
||||
>Bind Passwort (optional)
|
||||
<input
|
||||
name="ldap_bindPassword"
|
||||
id="ldap_bindPassword"
|
||||
type="password"
|
||||
value=""
|
||||
autocomplete="new-password"
|
||||
placeholder="Leer lassen um nicht zu ändern"
|
||||
/></label>
|
||||
</div>
|
||||
<label class="full-width"
|
||||
>User Search Filter
|
||||
<textarea
|
||||
name="ldap_userSearchFilter"
|
||||
id="ldap_userSearchFilter"
|
||||
rows="4"
|
||||
spellcheck="false"
|
||||
class="ldap-filter-ta"
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="form-grid-ldap-attr">
|
||||
<label
|
||||
>Username-Attribut
|
||||
<input
|
||||
name="ldap_usernameAttribute"
|
||||
id="ldap_usernameAttribute"
|
||||
placeholder="sAMAccountName"
|
||||
/></label>
|
||||
<label
|
||||
>Vorname-Attribut
|
||||
<input name="ldap_firstNameAttribute" id="ldap_firstNameAttribute" placeholder="givenName"
|
||||
/></label>
|
||||
<label
|
||||
>Nachname-Attribut
|
||||
<input name="ldap_lastNameAttribute" id="ldap_lastNameAttribute" placeholder="sn"
|
||||
/></label>
|
||||
</div>
|
||||
<label class="full-width"
|
||||
>Sync-Intervall (Minuten)
|
||||
<input
|
||||
name="ldap_syncIntervalMinutes"
|
||||
id="ldap_syncIntervalMinutes"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value="1440"
|
||||
/></label>
|
||||
<p class="muted ldap-hint">0 = nur manuelle Synchronisation</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card options-section sync-panel">
|
||||
<h3 class="options-section-title">Synchronisation</h3>
|
||||
<div class="sync-actions">
|
||||
<button type="button" class="btn-ldap-sync-now" id="btn-ldap-sync-now">
|
||||
Synchronisation jetzt starten
|
||||
</button>
|
||||
</div>
|
||||
<p class="muted sync-last-line" id="ldap-last-sync">Letzte Synchronisation: —</p>
|
||||
<h4 class="sync-log-title">Sync-Log (letzte 10 Einträge)</h4>
|
||||
<div class="table-wrap sync-log-table-wrap">
|
||||
<table class="sync-log-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Typ</th>
|
||||
<th>Status</th>
|
||||
<th>Benutzer synchronisiert</th>
|
||||
<th>Fehlermeldung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sync-log-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card options-section">
|
||||
<h3 class="options-section-title">TeamViewer</h3>
|
||||
<p class="muted">
|
||||
API-Aufrufe nutzen den Header
|
||||
<code>Authorization: Bearer <token></code> (nur das Token ohne das Wort „Bearer“
|
||||
eintragen).
|
||||
</p>
|
||||
<label
|
||||
>Bearer-Token
|
||||
<input
|
||||
name="tv_bearerToken"
|
||||
id="tv_bearerToken"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
placeholder="your_token"
|
||||
spellcheck="false"
|
||||
/></label>
|
||||
<label>Hinweise <textarea name="tv_notes" id="tv_notes" rows="2"></textarea></label>
|
||||
</div>
|
||||
<div class="options-actions">
|
||||
<button type="submit" class="btn-config-save">Konfiguration speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="/js/pages/options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
75
public/start.html
Normal file
75
public/start.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Start — SDS CRM</title>
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
<link rel="stylesheet" href="/css/pages/start.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1><a href="/start.html">SDS CRM</a></h1>
|
||||
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
|
||||
</header>
|
||||
<main id="app" class="main">
|
||||
<p id="page-loading" class="muted">Lade …</p>
|
||||
<p id="page-error" class="error" hidden></p>
|
||||
<div id="page-main" class="stack home-open-tickets" hidden>
|
||||
<div class="home-kpi-bar">
|
||||
<h2>Offene Tickets</h2>
|
||||
<div class="home-kpi-pills">
|
||||
<span class="kpi-pill"
|
||||
><span id="kpi-open" class="badge badge-open">0 Offen</span></span
|
||||
>
|
||||
<span class="kpi-pill"
|
||||
><span id="kpi-waiting" class="badge badge-waiting">0 Wartend</span></span
|
||||
>
|
||||
<span id="kpi-total" class="kpi-pill muted">gesamt: 0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="home-ticket-list" class="home-ticket-list"></div>
|
||||
<p id="home-empty" class="muted" hidden>Keine offenen Tickets.</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<template id="tpl-home-ticket">
|
||||
<article class="card home-ticket-card">
|
||||
<div class="home-ticket-head">
|
||||
<button
|
||||
type="button"
|
||||
class="home-ticket-collapse-btn"
|
||||
aria-expanded="false"
|
||||
title="Beschreibung und Ereignisse ein- oder ausblenden"
|
||||
>
|
||||
<span class="home-ticket-chevron" aria-hidden="true">▼</span>
|
||||
</button>
|
||||
<div class="home-ticket-head-main">
|
||||
<div class="home-ticket-titleline">
|
||||
<a class="js-ticket-link" href="#">Titel</a>
|
||||
<span class="badge js-status"></span>
|
||||
<span class="badge js-priority"></span>
|
||||
</div>
|
||||
<div class="home-ticket-meta-row">
|
||||
<span class="js-meta-machine"></span>
|
||||
<span><span class="muted">Standort:</span> <span class="js-meta-standort"></span></span>
|
||||
<span><span class="muted">Erstellt:</span> <span class="js-meta-created"></span></span>
|
||||
<span><span class="muted">Aktualisiert:</span> <span class="js-meta-updated"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="home-ticket-open js-ticket-open" href="#">Ticket öffnen →</a>
|
||||
</div>
|
||||
<div class="home-ticket-collapsible" hidden>
|
||||
<div class="home-ticket-context">
|
||||
<p class="home-ticket-context-h"><strong>Beschreibung</strong></p>
|
||||
<p class="home-ticket-desc js-desc"></p>
|
||||
<div class="js-machine-block"></div>
|
||||
</div>
|
||||
<div class="home-ticket-events js-events"></div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script type="module" src="/js/pages/start.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
182
public/ticket.html
Normal file
182
public/ticket.html
Normal file
@@ -0,0 +1,182 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Ticket — SDS CRM</title>
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
<link rel="stylesheet" href="/css/pages/detail.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1><a href="/start.html">SDS CRM</a></h1>
|
||||
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
|
||||
</header>
|
||||
<main id="app" class="main">
|
||||
<p id="page-loading" class="muted">Lade …</p>
|
||||
<div id="ticket-bad-id" hidden>
|
||||
<p class="error">Ungültige oder fehlende Ticket-ID.</p>
|
||||
<p><a href="/tickets.html">← Zur Übersicht</a></p>
|
||||
</div>
|
||||
<p id="page-error" class="error" hidden></p>
|
||||
<div id="page-main" class="stack" hidden>
|
||||
<p><a href="/tickets.html">← Zurück</a></p>
|
||||
<h2 id="ticket-title"></h2>
|
||||
|
||||
<div id="panel-ticket-view" class="card">
|
||||
<p>
|
||||
<strong>Status:</strong>
|
||||
<span id="t-status-badge" class="badge"></span>
|
||||
</p>
|
||||
<p><strong>Priorität:</strong> <span id="t-priority-label"></span></p>
|
||||
<p>
|
||||
<label for="t-sla-days"><strong>Fälligkeit (Bearbeitungszeit)</strong></label>
|
||||
<select id="t-sla-days" aria-label="Fälligkeit in Tagen">
|
||||
<option value="">Standard (2 Tage)</option>
|
||||
<option value="1">1 Tag</option>
|
||||
<option value="2">2 Tage</option>
|
||||
<option value="3">3 Tage</option>
|
||||
<option value="4">4 Tage</option>
|
||||
<option value="5">5 Tage</option>
|
||||
</select>
|
||||
</p>
|
||||
<p><strong>Beschreibung:</strong></p>
|
||||
<p id="t-description" style="white-space: pre-wrap"></p>
|
||||
<p id="t-machine-row" hidden>
|
||||
<strong>Maschine:</strong>
|
||||
<a id="t-machine-link" href="#"></a><span id="t-machine-suffix"></span>
|
||||
</p>
|
||||
<button type="button" id="btn-t-edit">Bearbeiten</button>
|
||||
</div>
|
||||
|
||||
<form id="panel-ticket-edit" class="card stack" hidden>
|
||||
<label>Titel <input name="title" id="tu-title" required /></label>
|
||||
<label
|
||||
>Beschreibung <textarea name="description" id="tu-desc" required></textarea></label
|
||||
>
|
||||
<div class="row">
|
||||
<label
|
||||
>Status
|
||||
<select name="status" id="tu-status">
|
||||
<option value="OPEN">Offen</option>
|
||||
<option value="WAITING">Warte auf Rückmeldung</option>
|
||||
<option value="DONE">Erledigt</option>
|
||||
</select>
|
||||
</label>
|
||||
<label
|
||||
>Priorität
|
||||
<select name="priority" id="tu-priority">
|
||||
<option value="LOW">Niedrig</option>
|
||||
<option value="MEDIUM">Mittel</option>
|
||||
<option value="HIGH">Hoch</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button type="submit">Speichern</button>
|
||||
<button type="button" class="secondary" id="tu-cancel">Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h3>Historie</h3>
|
||||
<div class="card">
|
||||
<h4>Event hinzufügen</h4>
|
||||
<form id="form-ev" class="stack">
|
||||
<label
|
||||
>Art
|
||||
<select id="ev-type-sel" name="type" required>
|
||||
<option value="NOTE">Notiz</option>
|
||||
<option value="CALL">Anruf</option>
|
||||
<option value="REMOTE">Remote</option>
|
||||
<option value="PART">Ersatzteil benötigt</option>
|
||||
<option value="ATTACHMENT">Anhang / Datei(en)</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="ev-field-group" data-ev-type="NOTE">
|
||||
<label>Beschreibung <textarea name="description_note" rows="3" required></textarea></label>
|
||||
</div>
|
||||
<div class="ev-field-group" data-ev-type="CALL" hidden>
|
||||
<label>Beschreibung <textarea name="description_call" rows="3"></textarea></label>
|
||||
<label>Rückrufnummer <span class="muted">(optional)</span>
|
||||
<input name="callbackNumber" type="tel" autocomplete="tel" /></label>
|
||||
</div>
|
||||
<div class="ev-field-group" data-ev-type="REMOTE" hidden>
|
||||
<label
|
||||
>Beschreibung
|
||||
<textarea
|
||||
name="description_remote"
|
||||
rows="3"
|
||||
placeholder="Was wurde remote erledigt?"
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="tv-device-row">
|
||||
<label
|
||||
>Benutzer (TeamViewer)
|
||||
<select id="tv-user-select" aria-label="TeamViewer Benutzer">
|
||||
<option value="">— zuerst Art „Remote“ wählen —</option>
|
||||
</select>
|
||||
</label>
|
||||
<label
|
||||
>Gerät / Session
|
||||
<select name="teamviewerDevice" id="tv-conn-select" disabled aria-label="TeamViewer Gerät">
|
||||
<option value="">— zuerst Benutzer wählen —</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="secondary" id="btn-tv-reload">Liste aktualisieren</button>
|
||||
</div>
|
||||
<p class="muted tv-conn-hint" id="tv-conn-hint"></p>
|
||||
</div>
|
||||
<div class="ev-field-group" data-ev-type="PART" hidden>
|
||||
<label>Artikelnummer <input name="articleNumber" /></label>
|
||||
<label>Bemerkung <span class="muted">(optional)</span>
|
||||
<textarea name="description_part" rows="2"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ev-field-group" data-ev-type="ATTACHMENT" hidden>
|
||||
<label>Beschreibung <span class="muted">(optional)</span>
|
||||
<textarea name="description_attachment" rows="2" placeholder="Kurzbeschreibung zu den Dateien"></textarea>
|
||||
</label>
|
||||
<label>Dateien
|
||||
<input
|
||||
type="file"
|
||||
id="ev-attachment-files"
|
||||
name="files"
|
||||
multiple
|
||||
accept="*/*"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">Event speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card table-wrap">
|
||||
<table class="events-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Zeitpunkt</th>
|
||||
<th scope="col">Art</th>
|
||||
<th scope="col">Inhalt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="events-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="sect-second-ticket" hidden>
|
||||
<h3>Weiteres Ticket für diese Maschine</h3>
|
||||
<p class="muted">Optional.</p>
|
||||
<div class="card">
|
||||
<form id="form-t2" class="stack">
|
||||
<div class="row">
|
||||
<label>Titel <input name="title" required /></label>
|
||||
<label>Beschreibung <textarea name="description" required></textarea></label>
|
||||
</div>
|
||||
<button type="submit">Ticket erstellen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="/js/pages/ticket-detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
96
public/tickets.html
Normal file
96
public/tickets.html
Normal file
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tickets — SDS CRM</title>
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
<link rel="stylesheet" href="/css/pages/tickets.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1><a href="/start.html">SDS CRM</a></h1>
|
||||
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
|
||||
</header>
|
||||
<main id="app" class="main">
|
||||
<p id="page-loading" class="muted">Lade …</p>
|
||||
<p id="page-error" class="error" hidden></p>
|
||||
<div id="page-main" class="stack" hidden>
|
||||
<h2>Tickets</h2>
|
||||
<div class="card">
|
||||
<h3>Neues Ticket</h3>
|
||||
<form id="form-new-ticket" class="stack ticket-new-form">
|
||||
<div class="ticket-form-machine">
|
||||
<label
|
||||
>Maschine
|
||||
<select name="machineId" id="sel-nm" required>
|
||||
<option value="">— wählen —</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="ticket-field-full">
|
||||
Titel
|
||||
<input name="title" type="text" required autocomplete="off" />
|
||||
</label>
|
||||
<label class="ticket-field-full">
|
||||
Beschreibung
|
||||
<textarea
|
||||
name="description"
|
||||
class="ticket-desc"
|
||||
required
|
||||
rows="6"
|
||||
></textarea>
|
||||
</label>
|
||||
<button type="submit">Ticket erstellen</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Filter</h3>
|
||||
<form id="form-filter" class="stack">
|
||||
<div class="row">
|
||||
<label
|
||||
>Status
|
||||
<select name="status">
|
||||
<option value="">Alle</option>
|
||||
<option value="OPEN">Offen</option>
|
||||
<option value="WAITING">Warte auf Rückmeldung</option>
|
||||
<option value="DONE">Erledigt</option>
|
||||
</select>
|
||||
</label>
|
||||
<label
|
||||
>Priorität
|
||||
<select name="priority">
|
||||
<option value="">Alle</option>
|
||||
<option value="LOW">Niedrig</option>
|
||||
<option value="MEDIUM">Mittel</option>
|
||||
<option value="HIGH">Hoch</option>
|
||||
</select>
|
||||
</label>
|
||||
<label
|
||||
>Maschine
|
||||
<select name="machineId" id="sel-fm">
|
||||
<option value="">Alle</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">Filter anwenden</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titel</th>
|
||||
<th>Status</th>
|
||||
<th>Priorität</th>
|
||||
<th>Maschine</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tickets-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="/js/pages/tickets.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
public/users.html
Normal file
59
public/users.html
Normal file
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Benutzer — SDS CRM</title>
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
<link rel="stylesheet" href="/css/pages/users.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1><a href="/start.html">SDS CRM</a></h1>
|
||||
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
|
||||
</header>
|
||||
<main id="app" class="main">
|
||||
<p id="page-loading" class="muted">Lade …</p>
|
||||
<p id="page-error" class="error" hidden></p>
|
||||
<div id="page-main" class="stack" hidden>
|
||||
<p><a href="/start.html">← Start</a></p>
|
||||
<h2>Benutzer</h2>
|
||||
<div class="card">
|
||||
<h3>Neuer Benutzer</h3>
|
||||
<form id="form-new-user" class="stack">
|
||||
<div class="row">
|
||||
<label>Benutzername <input name="username" required autocomplete="off" /></label>
|
||||
<label
|
||||
>Passwort
|
||||
<input name="password" type="password" required minlength="8" autocomplete="new-password"
|
||||
/></label>
|
||||
<label
|
||||
>Rolle
|
||||
<select name="role">
|
||||
<option value="user">Benutzer</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card table-wrap">
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benutzer</th>
|
||||
<th>Rolle</th>
|
||||
<th>Quelle</th>
|
||||
<th>Aktiv</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="/js/pages/users.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -87,8 +87,8 @@ if (replaceAll) {
|
||||
}
|
||||
|
||||
const insertMachine = db.prepare(
|
||||
`INSERT INTO machines (id, name, typ, seriennummer, standort, extras, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||
`INSERT INTO machines (id, name, typ, seriennummer, standort, list_status, extras, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, '', ?, datetime('now'))`,
|
||||
);
|
||||
|
||||
let imported = 0;
|
||||
|
||||
112
server/db.js
112
server/db.js
@@ -2,6 +2,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { mergeLegacyAttachmentEventsByDay } from './lib/merge-attachment-events.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dbPath =
|
||||
@@ -16,6 +17,12 @@ const machineCols = db.prepare('PRAGMA table_info(machines)').all();
|
||||
if (!machineCols.some((c) => c.name === 'extras')) {
|
||||
db.exec('ALTER TABLE machines ADD COLUMN extras TEXT');
|
||||
}
|
||||
const machineCols2 = db.prepare('PRAGMA table_info(machines)').all();
|
||||
if (!machineCols2.some((c) => c.name === 'list_status')) {
|
||||
db.exec(
|
||||
"ALTER TABLE machines ADD COLUMN list_status TEXT NOT NULL DEFAULT ''",
|
||||
);
|
||||
}
|
||||
|
||||
const hasCustomerId = machineCols.some((c) => c.name === 'customer_id');
|
||||
const tables = db
|
||||
@@ -30,6 +37,9 @@ const eventCols = db.prepare('PRAGMA table_info(events)').all();
|
||||
if (eventCols.length > 0 && !eventCols.some((c) => c.name === 'remote_duration_seconds')) {
|
||||
db.exec('ALTER TABLE events ADD COLUMN remote_duration_seconds INTEGER');
|
||||
}
|
||||
if (eventCols.length > 0 && !eventCols.some((c) => c.name === 'teamviewer_notes')) {
|
||||
db.exec('ALTER TABLE events ADD COLUMN teamviewer_notes TEXT');
|
||||
}
|
||||
const hasEventExtras = eventCols.some((c) => c.name === 'callback_number');
|
||||
if (eventCols.length > 0 && !hasEventExtras) {
|
||||
db.exec('BEGIN');
|
||||
@@ -44,10 +54,11 @@ if (eventCols.length > 0 && !hasEventExtras) {
|
||||
"teamviewer_id" TEXT,
|
||||
"article_number" TEXT,
|
||||
"remote_duration_seconds" INTEGER,
|
||||
"teamviewer_notes" TEXT,
|
||||
"created_at" TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY ("ticket_id") REFERENCES "tickets" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO events_new (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds, created_at)
|
||||
INSERT INTO events_new (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds, teamviewer_notes, created_at)
|
||||
SELECT
|
||||
id,
|
||||
ticket_id,
|
||||
@@ -57,6 +68,7 @@ if (eventCols.length > 0 && !hasEventExtras) {
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
created_at
|
||||
FROM events;
|
||||
DROP TABLE events;
|
||||
@@ -85,12 +97,13 @@ if (hasCustomerId || hasCustomersTable) {
|
||||
"typ" TEXT NOT NULL,
|
||||
"seriennummer" TEXT NOT NULL,
|
||||
"standort" TEXT NOT NULL,
|
||||
"list_status" TEXT NOT NULL DEFAULT '',
|
||||
"extras" TEXT,
|
||||
"created_at" TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
"updated_at" TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
INSERT INTO machines_new (id, name, typ, seriennummer, standort, extras, created_at, updated_at)
|
||||
SELECT id, name, typ, seriennummer, standort, extras, created_at, updated_at FROM machines;
|
||||
INSERT INTO machines_new (id, name, typ, seriennummer, standort, list_status, extras, created_at, updated_at)
|
||||
SELECT id, name, typ, seriennummer, standort, COALESCE(list_status, ''), extras, created_at, updated_at FROM machines;
|
||||
DROP TABLE machines;
|
||||
ALTER TABLE machines_new RENAME TO machines;
|
||||
`);
|
||||
@@ -160,4 +173,97 @@ if (!ldapLogTbl) {
|
||||
`);
|
||||
}
|
||||
|
||||
const ticketAttachmentsTbl = db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='ticket_attachments'",
|
||||
)
|
||||
.get();
|
||||
if (!ticketAttachmentsTbl) {
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE events_new (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"ticket_id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL CHECK ("type" IN ('NOTE', 'CALL', 'REMOTE', 'PART', 'SYSTEM', 'ATTACHMENT')),
|
||||
"description" TEXT NOT NULL,
|
||||
"callback_number" TEXT,
|
||||
"teamviewer_id" TEXT,
|
||||
"article_number" TEXT,
|
||||
"remote_duration_seconds" INTEGER,
|
||||
"teamviewer_notes" TEXT,
|
||||
"created_at" TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY ("ticket_id") REFERENCES "tickets" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO events_new (
|
||||
id, ticket_id, type, description, callback_number, teamviewer_id, article_number,
|
||||
remote_duration_seconds, teamviewer_notes, created_at
|
||||
)
|
||||
SELECT
|
||||
id, ticket_id, type, description, callback_number, teamviewer_id, article_number,
|
||||
remote_duration_seconds, teamviewer_notes, created_at
|
||||
FROM events;
|
||||
DROP TABLE events;
|
||||
ALTER TABLE events_new RENAME TO events;
|
||||
`);
|
||||
db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS events_ticket_id_idx ON "events" ("ticket_id")',
|
||||
);
|
||||
db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS events_created_at_idx ON "events" ("created_at")',
|
||||
);
|
||||
db.exec(`
|
||||
CREATE TABLE "ticket_attachments" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"event_id" TEXT NOT NULL,
|
||||
"original_name" TEXT NOT NULL,
|
||||
"stored_path" TEXT NOT NULL,
|
||||
"mime_type" TEXT,
|
||||
"size_bytes" INTEGER NOT NULL,
|
||||
"created_at" TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY ("event_id") REFERENCES "events" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ticket_attachments_event_idx ON "ticket_attachments" ("event_id");
|
||||
`);
|
||||
db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const hasTicketAttachments = db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='ticket_attachments'",
|
||||
)
|
||||
.get();
|
||||
const attachmentMergeDone = db
|
||||
.prepare('SELECT 1 AS ok FROM app_settings WHERE key = ?')
|
||||
.get('attachment_events_merge_day_v1');
|
||||
if (hasTicketAttachments && !attachmentMergeDone) {
|
||||
try {
|
||||
mergeLegacyAttachmentEventsByDay(db);
|
||||
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(
|
||||
'attachment_events_merge_day_v1',
|
||||
'1',
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('CRM: Zusammenführung Anhang-Events fehlgeschlagen:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const ticketCols = db.prepare('PRAGMA table_info(tickets)').all();
|
||||
if (!ticketCols.some((c) => c.name === 'sla_days')) {
|
||||
db.exec('ALTER TABLE tickets ADD COLUMN sla_days INTEGER');
|
||||
}
|
||||
if (!ticketCols.some((c) => c.name === 'sla_anchor_at')) {
|
||||
db.exec('ALTER TABLE tickets ADD COLUMN sla_anchor_at TEXT');
|
||||
}
|
||||
const ticketCols2 = db.prepare('PRAGMA table_info(tickets)').all();
|
||||
if (ticketCols2.some((c) => c.name === 'sla_anchor_at')) {
|
||||
db.prepare(
|
||||
'UPDATE tickets SET sla_anchor_at = created_at WHERE sla_anchor_at IS NULL',
|
||||
).run();
|
||||
}
|
||||
|
||||
export default db;
|
||||
|
||||
741
server/index.js
741
server/index.js
@@ -1,17 +1,13 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import db from './db.js';
|
||||
import { getSyncStatus, performLdapSync } from './ldap-sync.js';
|
||||
import { hashPassword, verifyPassword } from './password.js';
|
||||
import {
|
||||
computeRemoteDurationSeconds,
|
||||
registerTeamViewerRoutes,
|
||||
} from './teamviewer.js';
|
||||
import { restartLdapSyncScheduler } from './integrations.js';
|
||||
import { createApiRouter } from './routes/api/index.js';
|
||||
import { createAdminRouter } from './routes/admin/index.js';
|
||||
import authRouter from './routes/auth.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -42,734 +38,21 @@ app.use(
|
||||
}),
|
||||
);
|
||||
|
||||
const UUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
app.use('/auth', authRouter);
|
||||
app.use('/api', createApiRouter());
|
||||
app.use('/api', createAdminRouter());
|
||||
|
||||
function badRequest(res, msg) {
|
||||
return res.status(400).json({ message: msg });
|
||||
}
|
||||
|
||||
function parseJsonField(v) {
|
||||
if (v == null) return undefined;
|
||||
if (typeof v === 'object') return v;
|
||||
return JSON.parse(v);
|
||||
}
|
||||
|
||||
function mapMachine(r) {
|
||||
const o = {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
typ: r.typ,
|
||||
seriennummer: r.seriennummer,
|
||||
standort: r.standort,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
};
|
||||
if (r.extras != null && String(r.extras).trim() !== '') {
|
||||
try {
|
||||
o.extras =
|
||||
typeof r.extras === 'string' ? JSON.parse(r.extras) : r.extras;
|
||||
} catch {
|
||||
o.extras = null;
|
||||
}
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
function mapTicket(r) {
|
||||
const machine_row = parseJsonField(r.machine_row);
|
||||
const t = {
|
||||
id: r.id,
|
||||
machineId: r.machine_id,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
status: r.status,
|
||||
priority: r.priority,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
};
|
||||
if (machine_row) {
|
||||
t.machine = mapMachine(machine_row);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
function mapEvent(r) {
|
||||
return {
|
||||
id: r.id,
|
||||
ticketId: r.ticket_id,
|
||||
type: r.type,
|
||||
description: r.description,
|
||||
createdAt: r.created_at,
|
||||
callbackNumber: r.callback_number ?? null,
|
||||
teamviewerId: r.teamviewer_id ?? null,
|
||||
articleNumber: r.article_number ?? null,
|
||||
remoteDurationSeconds:
|
||||
r.remote_duration_seconds != null ? r.remote_duration_seconds : null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapPublicUser(r) {
|
||||
return {
|
||||
id: r.id,
|
||||
username: r.username,
|
||||
role: r.role,
|
||||
active: Boolean(r.active),
|
||||
source: r.source,
|
||||
ldapDn: r.ldap_dn || null,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
const ticketJoinSelect = `
|
||||
SELECT t.*,
|
||||
json_object(
|
||||
'id', m.id,
|
||||
'name', m.name,
|
||||
'typ', m.typ,
|
||||
'seriennummer', m.seriennummer,
|
||||
'standort', m.standort,
|
||||
'extras', m.extras,
|
||||
'created_at', m.created_at,
|
||||
'updated_at', m.updated_at
|
||||
) AS machine_row
|
||||
FROM tickets t
|
||||
JOIN machines m ON m.id = t.machine_id`;
|
||||
|
||||
const DEFAULT_INTEGRATIONS = {
|
||||
ldap: {
|
||||
serverUrl: '',
|
||||
bindDn: '',
|
||||
bindPassword: '',
|
||||
searchBase: '',
|
||||
userSearchFilter: '',
|
||||
userFilter: '',
|
||||
usernameAttribute: 'sAMAccountName',
|
||||
firstNameAttribute: 'givenName',
|
||||
lastNameAttribute: 'sn',
|
||||
syncIntervalMinutes: 1440,
|
||||
syncEnabled: false,
|
||||
syncNotes: '',
|
||||
},
|
||||
/* TeamViewer: Authorization: Bearer <token> */
|
||||
teamviewer: {
|
||||
bearerToken: '',
|
||||
notes: '',
|
||||
},
|
||||
};
|
||||
|
||||
function loadIntegrations() {
|
||||
const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('integrations');
|
||||
const base = structuredClone(DEFAULT_INTEGRATIONS);
|
||||
if (!row?.value) return base;
|
||||
try {
|
||||
const s = JSON.parse(row.value);
|
||||
if (s.ldap && typeof s.ldap === 'object') Object.assign(base.ldap, s.ldap);
|
||||
const ld = base.ldap;
|
||||
if (!ld.userSearchFilter && ld.userFilter) ld.userSearchFilter = ld.userFilter;
|
||||
if (s.teamviewer && typeof s.teamviewer === 'object')
|
||||
Object.assign(base.teamviewer, s.teamviewer);
|
||||
const tv = base.teamviewer;
|
||||
if (!tv.bearerToken && tv.apiToken) tv.bearerToken = tv.apiToken;
|
||||
if (tv.notes == null && tv.apiNotes) tv.notes = tv.apiNotes;
|
||||
return base;
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
function saveIntegrations(obj) {
|
||||
const json = JSON.stringify(obj);
|
||||
db.prepare(
|
||||
`INSERT INTO app_settings (key, value) VALUES ('integrations', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
).run(json);
|
||||
}
|
||||
|
||||
let ldapSyncTimer = null;
|
||||
|
||||
function restartLdapSyncScheduler() {
|
||||
if (ldapSyncTimer) {
|
||||
clearInterval(ldapSyncTimer);
|
||||
ldapSyncTimer = null;
|
||||
}
|
||||
const cfg = loadIntegrations().ldap;
|
||||
if (!cfg.syncEnabled) return;
|
||||
const m = Math.max(0, Number(cfg.syncIntervalMinutes) || 0);
|
||||
if (m <= 0) return;
|
||||
ldapSyncTimer = setInterval(() => {
|
||||
performLdapSync(db, loadIntegrations, 'automatic').catch((err) =>
|
||||
console.error('LDAP auto-sync:', err),
|
||||
);
|
||||
}, m * 60 * 1000);
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (!req.session?.userId) {
|
||||
return res.status(401).json({ message: 'Nicht angemeldet' });
|
||||
}
|
||||
const u = db
|
||||
.prepare(
|
||||
'SELECT id, active FROM users WHERE id = ?',
|
||||
)
|
||||
.get(req.session.userId);
|
||||
if (!u || !u.active) {
|
||||
req.session.destroy(() => {});
|
||||
return res.status(401).json({ message: 'Nicht angemeldet' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.session?.role !== 'admin') {
|
||||
return res.status(403).json({ message: 'Administratorrechte erforderlich.' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/** ——— Öffentlich: Auth ——— */
|
||||
app.get('/auth/status', (req, res) => {
|
||||
const count = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
|
||||
const needsBootstrap = count === 0;
|
||||
if (needsBootstrap) {
|
||||
return res.json({ needsBootstrap: true, loggedIn: false, user: null });
|
||||
}
|
||||
if (!req.session?.userId) {
|
||||
return res.json({ needsBootstrap: false, loggedIn: false, user: null });
|
||||
}
|
||||
const u = db
|
||||
.prepare(
|
||||
'SELECT id, username, role, active FROM users WHERE id = ?',
|
||||
)
|
||||
.get(req.session.userId);
|
||||
if (!u || !u.active) {
|
||||
req.session.destroy(() => {});
|
||||
return res.json({ needsBootstrap: false, loggedIn: false, user: null });
|
||||
}
|
||||
res.json({
|
||||
needsBootstrap: false,
|
||||
loggedIn: true,
|
||||
user: { id: u.id, username: u.username, role: u.role },
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/auth/bootstrap', async (req, res) => {
|
||||
const count = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
|
||||
if (count > 0) {
|
||||
return res.status(403).json({ message: 'Initialisierung nicht mehr möglich.' });
|
||||
}
|
||||
const { username, password } = req.body || {};
|
||||
const un = String(username || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!un || !password || password.length < 8) {
|
||||
return badRequest(res, 'Benutzername und Passwort (min. 8 Zeichen) erforderlich.');
|
||||
}
|
||||
const id = randomUUID();
|
||||
const ph = await hashPassword(password);
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, username, password_hash, role, source, active, updated_at)
|
||||
VALUES (?, ?, ?, 'admin', 'local', 1, datetime('now'))`,
|
||||
).run(id, un, ph);
|
||||
req.session.userId = id;
|
||||
req.session.role = 'admin';
|
||||
req.session.username = un;
|
||||
res.status(201).json({
|
||||
user: { id, username: un, role: 'admin' },
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/auth/login', async (req, res) => {
|
||||
const { username, password } = req.body || {};
|
||||
const un = String(username || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!un || !password) {
|
||||
return badRequest(res, 'Benutzername und Passwort erforderlich.');
|
||||
}
|
||||
const u = db.prepare('SELECT * FROM users WHERE username = ?').get(un);
|
||||
if (!u || !u.active) {
|
||||
return res.status(401).json({ message: 'Ungültige Zugangsdaten.' });
|
||||
}
|
||||
if (!u.password_hash) {
|
||||
return res.status(401).json({
|
||||
message: 'Kein lokales Passwort (LDAP). Anmeldung folgt mit Verzeichnis-Sync.',
|
||||
});
|
||||
}
|
||||
const ok = await verifyPassword(password, u.password_hash);
|
||||
if (!ok) return res.status(401).json({ message: 'Ungültige Zugangsdaten.' });
|
||||
req.session.userId = u.id;
|
||||
req.session.role = u.role;
|
||||
req.session.username = u.username;
|
||||
res.json({
|
||||
user: { id: u.id, username: u.username, role: u.role },
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/auth/logout', (req, res) => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) return res.status(500).json({ message: 'Abmelden fehlgeschlagen.' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
/** ——— Geschützte API ——— */
|
||||
const api = express.Router();
|
||||
api.use(requireAuth);
|
||||
|
||||
api.get('/machines', (_req, res) => {
|
||||
const rows = db
|
||||
.prepare('SELECT * FROM machines ORDER BY seriennummer ASC')
|
||||
.all();
|
||||
res.json(rows.map(mapMachine));
|
||||
});
|
||||
|
||||
api.post('/machines', (req, res) => {
|
||||
const { name, typ, seriennummer, standort } = req.body || {};
|
||||
if (!name || !typ || !seriennummer || !standort) {
|
||||
return badRequest(res, 'Pflichtfelder fehlen.');
|
||||
}
|
||||
const id = randomUUID();
|
||||
const row = db
|
||||
.prepare(
|
||||
`INSERT INTO machines (id, name, typ, seriennummer, standort, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now')) RETURNING *`,
|
||||
)
|
||||
.get(id, name, typ, seriennummer, standort);
|
||||
res.status(201).json(mapMachine(row));
|
||||
});
|
||||
|
||||
api.get('/machines/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const row = db.prepare('SELECT * FROM machines WHERE id = ?').get(id);
|
||||
if (!row) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
res.json(mapMachine(row));
|
||||
});
|
||||
|
||||
api.put('/machines/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const cur = db.prepare('SELECT * FROM machines WHERE id = ?').get(id);
|
||||
if (!cur) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const b = req.body || {};
|
||||
const next = {
|
||||
name: b.name ?? cur.name,
|
||||
typ: b.typ ?? cur.typ,
|
||||
seriennummer: b.seriennummer ?? cur.seriennummer,
|
||||
standort: b.standort ?? cur.standort,
|
||||
};
|
||||
let extrasJson = cur.extras;
|
||||
if (Object.prototype.hasOwnProperty.call(b, 'extras')) {
|
||||
if (b.extras === null || b.extras === '') {
|
||||
extrasJson = null;
|
||||
} else if (typeof b.extras === 'object' && b.extras !== null) {
|
||||
try {
|
||||
extrasJson = JSON.stringify(b.extras);
|
||||
} catch {
|
||||
return badRequest(res, 'extras ist kein gültiges JSON-Objekt.');
|
||||
}
|
||||
} else if (typeof b.extras === 'string') {
|
||||
try {
|
||||
JSON.parse(b.extras);
|
||||
extrasJson = b.extras;
|
||||
} catch {
|
||||
return badRequest(res, 'extras ist kein gültiger JSON-String.');
|
||||
}
|
||||
} else {
|
||||
return badRequest(res, 'extras hat ein ungültiges Format.');
|
||||
}
|
||||
}
|
||||
const row = db
|
||||
.prepare(
|
||||
`UPDATE machines SET name = ?, typ = ?, seriennummer = ?, standort = ?, extras = ?, updated_at = datetime('now')
|
||||
WHERE id = ? RETURNING *`,
|
||||
)
|
||||
.get(next.name, next.typ, next.seriennummer, next.standort, extrasJson, id);
|
||||
res.json(mapMachine(row));
|
||||
});
|
||||
|
||||
api.delete('/machines/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const cur = db.prepare('SELECT * FROM machines WHERE id = ?').get(id);
|
||||
if (!cur) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const tc = db
|
||||
.prepare('SELECT COUNT(*) AS c FROM tickets WHERE machine_id = ?')
|
||||
.get(id);
|
||||
if (tc.c > 0) {
|
||||
return res.status(409).json({
|
||||
message:
|
||||
'Maschine kann nicht gelöscht werden: Es existieren noch zugeordnete Tickets.',
|
||||
});
|
||||
}
|
||||
const row = db
|
||||
.prepare('DELETE FROM machines WHERE id = ? RETURNING *')
|
||||
.get(id);
|
||||
res.json(mapMachine(row));
|
||||
});
|
||||
|
||||
api.get('/tickets', (req, res) => {
|
||||
const { status, priority, machineId, open } = req.query;
|
||||
const cond = ['1=1'];
|
||||
const params = [];
|
||||
const openFilter = open === '1' || open === 'true';
|
||||
if (openFilter) {
|
||||
cond.push("t.status IN ('OPEN', 'WAITING')");
|
||||
} else if (status) {
|
||||
cond.push('t.status = ?');
|
||||
params.push(status);
|
||||
}
|
||||
if (priority) {
|
||||
cond.push('t.priority = ?');
|
||||
params.push(priority);
|
||||
}
|
||||
if (machineId) {
|
||||
cond.push('t.machine_id = ?');
|
||||
params.push(machineId);
|
||||
}
|
||||
const sql = `${ticketJoinSelect} WHERE ${cond.join(' AND ')} ORDER BY t.updated_at DESC`;
|
||||
const rows = db.prepare(sql).all(...params);
|
||||
res.json(rows.map(mapTicket));
|
||||
});
|
||||
|
||||
api.post('/tickets', (req, res) => {
|
||||
const { machineId, title, description, status, priority } = req.body || {};
|
||||
if (!machineId || !title || !description) {
|
||||
return badRequest(res, 'Pflichtfelder fehlen.');
|
||||
}
|
||||
const m = db
|
||||
.prepare('SELECT 1 AS ok FROM machines WHERE id = ?')
|
||||
.get(machineId);
|
||||
if (!m) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const st = status || 'OPEN';
|
||||
const pr = priority || 'MEDIUM';
|
||||
const tid = randomUUID();
|
||||
db.prepare(
|
||||
`INSERT INTO tickets (id, machine_id, title, description, status, priority, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||
).run(tid, machineId, title, description, st, pr);
|
||||
const full = db
|
||||
.prepare(`${ticketJoinSelect} WHERE t.id = ?`)
|
||||
.get(tid);
|
||||
res.status(201).json(mapTicket(full));
|
||||
});
|
||||
|
||||
api.get('/tickets/:id/events', (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const ex = db.prepare('SELECT 1 AS ok FROM tickets WHERE id = ?').get(id);
|
||||
if (!ex) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const rows = db
|
||||
.prepare(
|
||||
'SELECT * FROM events WHERE ticket_id = ? ORDER BY created_at DESC',
|
||||
)
|
||||
.all(id);
|
||||
res.json(rows.map(mapEvent));
|
||||
});
|
||||
|
||||
api.get('/tickets/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const row = db.prepare(`${ticketJoinSelect} WHERE t.id = ?`).get(id);
|
||||
if (!row) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
res.json(mapTicket(row));
|
||||
});
|
||||
|
||||
api.put('/tickets/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const cur = db.prepare('SELECT * FROM tickets WHERE id = ?').get(id);
|
||||
if (!cur) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const b = req.body || {};
|
||||
const next = {
|
||||
title: b.title ?? cur.title,
|
||||
description: b.description ?? cur.description,
|
||||
status: b.status ?? cur.status,
|
||||
priority: b.priority ?? cur.priority,
|
||||
};
|
||||
|
||||
const lines = [];
|
||||
if (b.status !== undefined && b.status !== cur.status) {
|
||||
lines.push(`Status: ${cur.status} → ${b.status}`);
|
||||
}
|
||||
if (b.priority !== undefined && b.priority !== cur.priority) {
|
||||
lines.push(`Priorität: ${cur.priority} → ${b.priority}`);
|
||||
}
|
||||
if (b.title !== undefined && b.title !== cur.title) lines.push('Titel geändert');
|
||||
if (b.description !== undefined && b.description !== cur.description) {
|
||||
lines.push('Beschreibung geändert');
|
||||
}
|
||||
if (lines.length > 0) {
|
||||
const eid = randomUUID();
|
||||
db.prepare(
|
||||
`INSERT INTO events (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds)
|
||||
VALUES (?, ?, 'SYSTEM', ?, NULL, NULL, NULL, NULL)`,
|
||||
).run(eid, id, lines.join('; '));
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE tickets SET title = ?, description = ?, status = ?, priority = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
).run(next.title, next.description, next.status, next.priority, id);
|
||||
|
||||
const row = db.prepare(`${ticketJoinSelect} WHERE t.id = ?`).get(id);
|
||||
res.json(mapTicket(row));
|
||||
});
|
||||
|
||||
const EVENT_TYPES_USER = new Set(['NOTE', 'CALL', 'REMOTE', 'PART']);
|
||||
|
||||
registerTeamViewerRoutes(api, loadIntegrations);
|
||||
|
||||
api.post('/events', (req, res) => {
|
||||
const b = req.body || {};
|
||||
const ticketId = b.ticketId;
|
||||
const type = b.type;
|
||||
if (!ticketId || !type || !EVENT_TYPES_USER.has(type)) {
|
||||
return badRequest(res, 'Pflichtfelder fehlen oder ungültiger Typ.');
|
||||
}
|
||||
const t = db
|
||||
.prepare('SELECT 1 AS ok FROM tickets WHERE id = ?')
|
||||
.get(ticketId);
|
||||
if (!t) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
|
||||
const desc = b.description != null ? String(b.description).trim() : '';
|
||||
const callbackNumber =
|
||||
b.callbackNumber != null ? String(b.callbackNumber).trim() : '';
|
||||
const teamviewerId =
|
||||
b.teamviewerId != null ? String(b.teamviewerId).trim() : '';
|
||||
const articleNumber =
|
||||
b.articleNumber != null ? String(b.articleNumber).trim() : '';
|
||||
|
||||
let description = desc;
|
||||
let cb = callbackNumber || null;
|
||||
let tv = teamviewerId || null;
|
||||
let art = articleNumber || null;
|
||||
|
||||
if (type === 'NOTE') {
|
||||
if (!description) return badRequest(res, 'Beschreibung fehlt.');
|
||||
cb = null;
|
||||
tv = null;
|
||||
art = null;
|
||||
} else if (type === 'CALL') {
|
||||
if (!description) return badRequest(res, 'Beschreibung fehlt.');
|
||||
if (!callbackNumber) return badRequest(res, 'Rückrufnummer fehlt.');
|
||||
tv = null;
|
||||
art = null;
|
||||
} else if (type === 'REMOTE') {
|
||||
if (!description?.trim() && !tv) {
|
||||
return badRequest(res, 'Beschreibung oder TeamViewer-Gerät erforderlich.');
|
||||
}
|
||||
cb = null;
|
||||
art = null;
|
||||
} else if (type === 'PART') {
|
||||
if (!articleNumber) return badRequest(res, 'Artikelnummer fehlt.');
|
||||
description = desc;
|
||||
cb = null;
|
||||
tv = null;
|
||||
}
|
||||
|
||||
let remoteDurationSeconds = null;
|
||||
if (type === 'REMOTE') {
|
||||
remoteDurationSeconds = computeRemoteDurationSeconds(
|
||||
b.teamviewerStartDate,
|
||||
b.teamviewerEndDate,
|
||||
);
|
||||
}
|
||||
|
||||
const eid = randomUUID();
|
||||
const row = db
|
||||
.prepare(
|
||||
`INSERT INTO events (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
|
||||
)
|
||||
.get(
|
||||
eid,
|
||||
ticketId,
|
||||
type,
|
||||
description,
|
||||
cb,
|
||||
tv,
|
||||
art,
|
||||
remoteDurationSeconds,
|
||||
);
|
||||
res.status(201).json(mapEvent(row));
|
||||
});
|
||||
|
||||
app.use('/api', api);
|
||||
|
||||
/** ——— Admin ——— */
|
||||
const admin = express.Router();
|
||||
admin.use(requireAuth, requireAdmin);
|
||||
|
||||
admin.get('/users', (_req, res) => {
|
||||
const rows = db
|
||||
.prepare(
|
||||
'SELECT id, username, role, source, active, ldap_dn, created_at, updated_at FROM users ORDER BY username ASC',
|
||||
)
|
||||
.all();
|
||||
res.json(rows.map(mapPublicUser));
|
||||
});
|
||||
|
||||
admin.post('/users', async (req, res) => {
|
||||
const { username, password, role } = req.body || {};
|
||||
const un = String(username || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!un || !password) return badRequest(res, 'Benutzername und Passwort erforderlich.');
|
||||
const r = role === 'admin' ? 'admin' : 'user';
|
||||
const id = randomUUID();
|
||||
const ph = await hashPassword(password);
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, username, password_hash, role, source, active, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'local', 1, datetime('now'))`,
|
||||
).run(id, un, ph, r);
|
||||
} catch (e) {
|
||||
if (String(e.message || e).includes('UNIQUE')) {
|
||||
return res.status(409).json({ message: 'Benutzername bereits vergeben.' });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||
res.status(201).json(mapPublicUser(row));
|
||||
});
|
||||
|
||||
admin.put('/users/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const cur = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||
if (!cur) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const b = req.body || {};
|
||||
|
||||
if (b.password != null && String(b.password).length > 0) {
|
||||
if (cur.source !== 'local') {
|
||||
return badRequest(res, 'Passwort nur für lokale Benutzer änderbar.');
|
||||
}
|
||||
const ph = await hashPassword(b.password);
|
||||
db.prepare(
|
||||
'UPDATE users SET password_hash = ?, updated_at = datetime(\'now\') WHERE id = ?',
|
||||
).run(ph, id);
|
||||
}
|
||||
if (b.role !== undefined) {
|
||||
if (b.role !== 'admin' && b.role !== 'user') {
|
||||
return badRequest(res, 'Ungültige Rolle.');
|
||||
}
|
||||
const admins = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND active = 1",
|
||||
)
|
||||
.get().c;
|
||||
if (cur.role === 'admin' && b.role === 'user' && admins <= 1) {
|
||||
return res.status(400).json({ message: 'Letzter Administrator kann nicht herabgestuft werden.' });
|
||||
}
|
||||
db.prepare('UPDATE users SET role = ?, updated_at = datetime(\'now\') WHERE id = ?').run(
|
||||
b.role,
|
||||
id,
|
||||
);
|
||||
}
|
||||
if (b.active !== undefined) {
|
||||
const active = b.active ? 1 : 0;
|
||||
const admins = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND active = 1",
|
||||
)
|
||||
.get().c;
|
||||
if (cur.role === 'admin' && cur.active && !active && admins <= 1) {
|
||||
return res.status(400).json({ message: 'Letzter Administrator kann nicht deaktiviert werden.' });
|
||||
}
|
||||
db.prepare('UPDATE users SET active = ?, updated_at = datetime(\'now\') WHERE id = ?').run(
|
||||
active,
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||
res.json(mapPublicUser(row));
|
||||
});
|
||||
|
||||
admin.delete('/users/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
if (id === req.session.userId) {
|
||||
return res.status(400).json({ message: 'Eigenes Konto kann nicht gelöscht werden.' });
|
||||
}
|
||||
const cur = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||
if (!cur) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
if (cur.role === 'admin') {
|
||||
const admins = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND active = 1",
|
||||
)
|
||||
.get().c;
|
||||
if (admins <= 1) {
|
||||
return res.status(400).json({ message: 'Letzter Administrator kann nicht gelöscht werden.' });
|
||||
}
|
||||
}
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
admin.get('/settings/integrations', (_req, res) => {
|
||||
res.json(loadIntegrations());
|
||||
});
|
||||
|
||||
admin.put('/settings/integrations', (req, res) => {
|
||||
const b = req.body || {};
|
||||
const cur = loadIntegrations();
|
||||
if (b.ldap && typeof b.ldap === 'object') {
|
||||
const incoming = { ...b.ldap };
|
||||
if (incoming.bindPassword === '' || incoming.bindPassword == null) {
|
||||
delete incoming.bindPassword;
|
||||
}
|
||||
if (incoming.syncIntervalMinutes != null) {
|
||||
const n = Number(incoming.syncIntervalMinutes);
|
||||
incoming.syncIntervalMinutes = Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 1440;
|
||||
}
|
||||
Object.assign(cur.ldap, incoming);
|
||||
if (b.ldap.userSearchFilter != null) {
|
||||
cur.ldap.userFilter = String(b.ldap.userSearchFilter);
|
||||
}
|
||||
}
|
||||
if (b.teamviewer && typeof b.teamviewer === 'object') {
|
||||
const inc = { ...b.teamviewer };
|
||||
if (inc.bearerToken === '' || inc.bearerToken == null) {
|
||||
delete inc.bearerToken;
|
||||
}
|
||||
Object.assign(cur.teamviewer, inc);
|
||||
}
|
||||
saveIntegrations(cur);
|
||||
restartLdapSyncScheduler();
|
||||
res.json(loadIntegrations());
|
||||
});
|
||||
|
||||
admin.get('/ldap/sync-status', (_req, res) => {
|
||||
res.json(getSyncStatus(db));
|
||||
});
|
||||
|
||||
admin.post('/ldap/sync', async (_req, res) => {
|
||||
const r = await performLdapSync(db, loadIntegrations, 'manual');
|
||||
if (r.skipped) {
|
||||
return res.status(409).json({ message: r.message });
|
||||
}
|
||||
res.json(r);
|
||||
});
|
||||
|
||||
app.use('/api', admin);
|
||||
|
||||
/** Unbekannte /api/*-Routen: JSON 404 — verhindert SPA index.html (HTML) mit 200 bei falscher Route/alter Serverversion */
|
||||
/** Unbekannte /api/*-Routen: JSON 404 */
|
||||
app.use('/api', (req, res) => {
|
||||
res.status(404).json({ message: 'API nicht gefunden' });
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
|
||||
/** SPA: Direktaufrufe wie /tickets ohne Hash liefern index.html (Client-Routing) */
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
|
||||
app.get('/', (_req, res) => {
|
||||
res.redirect(302, '/start.html');
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
|
||||
app.listen(PORT, () => {
|
||||
restartLdapSyncScheduler();
|
||||
console.log(`CRM-Server http://localhost:${PORT}`);
|
||||
|
||||
69
server/integrations.js
Normal file
69
server/integrations.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import db from './db.js';
|
||||
import { performLdapSync } from './ldap-sync.js';
|
||||
|
||||
const DEFAULT_INTEGRATIONS = {
|
||||
ldap: {
|
||||
serverUrl: '',
|
||||
bindDn: '',
|
||||
bindPassword: '',
|
||||
searchBase: '',
|
||||
userSearchFilter: '',
|
||||
userFilter: '',
|
||||
usernameAttribute: 'sAMAccountName',
|
||||
firstNameAttribute: 'givenName',
|
||||
lastNameAttribute: 'sn',
|
||||
syncIntervalMinutes: 1440,
|
||||
syncEnabled: false,
|
||||
syncNotes: '',
|
||||
},
|
||||
teamviewer: {
|
||||
bearerToken: '',
|
||||
notes: '',
|
||||
},
|
||||
};
|
||||
|
||||
export function loadIntegrations() {
|
||||
const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('integrations');
|
||||
const base = structuredClone(DEFAULT_INTEGRATIONS);
|
||||
if (!row?.value) return base;
|
||||
try {
|
||||
const s = JSON.parse(row.value);
|
||||
if (s.ldap && typeof s.ldap === 'object') Object.assign(base.ldap, s.ldap);
|
||||
const ld = base.ldap;
|
||||
if (!ld.userSearchFilter && ld.userFilter) ld.userSearchFilter = ld.userFilter;
|
||||
if (s.teamviewer && typeof s.teamviewer === 'object')
|
||||
Object.assign(base.teamviewer, s.teamviewer);
|
||||
const tv = base.teamviewer;
|
||||
if (!tv.bearerToken && tv.apiToken) tv.bearerToken = tv.apiToken;
|
||||
if (tv.notes == null && tv.apiNotes) tv.notes = tv.apiNotes;
|
||||
return base;
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveIntegrations(obj) {
|
||||
const json = JSON.stringify(obj);
|
||||
db.prepare(
|
||||
`INSERT INTO app_settings (key, value) VALUES ('integrations', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
).run(json);
|
||||
}
|
||||
|
||||
let ldapSyncTimer = null;
|
||||
|
||||
export function restartLdapSyncScheduler() {
|
||||
if (ldapSyncTimer) {
|
||||
clearInterval(ldapSyncTimer);
|
||||
ldapSyncTimer = null;
|
||||
}
|
||||
const cfg = loadIntegrations().ldap;
|
||||
if (!cfg.syncEnabled) return;
|
||||
const m = Math.max(0, Number(cfg.syncIntervalMinutes) || 0);
|
||||
if (m <= 0) return;
|
||||
ldapSyncTimer = setInterval(() => {
|
||||
performLdapSync(db, loadIntegrations, 'automatic').catch((err) =>
|
||||
console.error('LDAP auto-sync:', err),
|
||||
);
|
||||
}, m * 60 * 1000);
|
||||
}
|
||||
6
server/lib/http.js
Normal file
6
server/lib/http.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export const UUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function badRequest(res, msg) {
|
||||
return res.status(400).json({ message: msg });
|
||||
}
|
||||
138
server/lib/mappers.js
Normal file
138
server/lib/mappers.js
Normal file
@@ -0,0 +1,138 @@
|
||||
export function parseJsonField(v) {
|
||||
if (v == null) return undefined;
|
||||
if (typeof v === 'object') return v;
|
||||
return JSON.parse(v);
|
||||
}
|
||||
|
||||
export function mapMachine(r) {
|
||||
const o = {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
typ: r.typ,
|
||||
seriennummer: r.seriennummer,
|
||||
standort: r.standort,
|
||||
listStatus:
|
||||
r.list_status != null && String(r.list_status).trim() !== ''
|
||||
? String(r.list_status).trim()
|
||||
: '',
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
};
|
||||
if (r.extras != null && String(r.extras).trim() !== '') {
|
||||
try {
|
||||
o.extras =
|
||||
typeof r.extras === 'string' ? JSON.parse(r.extras) : r.extras;
|
||||
} catch {
|
||||
o.extras = null;
|
||||
}
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
export function mapTicket(r) {
|
||||
const machine_row = parseJsonField(r.machine_row);
|
||||
const t = {
|
||||
id: r.id,
|
||||
machineId: r.machine_id,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
status: r.status,
|
||||
priority: r.priority,
|
||||
slaDays: r.sla_days != null ? r.sla_days : null,
|
||||
slaAnchorAt: r.sla_anchor_at ?? null,
|
||||
dueAt: r.sla_due_at ?? null,
|
||||
isOverdue: Boolean(r.sla_is_overdue),
|
||||
createdAt: r.created_at,
|
||||
/** Letzte Änderung: neueres aus Ticket-Zeile oder letztem Event (für Anzeige „Aktualisiert“). */
|
||||
updatedAt: r.ticket_last_activity_at ?? r.updated_at,
|
||||
};
|
||||
if (machine_row) {
|
||||
t.machine = mapMachine(machine_row);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
function mapAttachmentRow(a, ticketId) {
|
||||
return {
|
||||
id: a.id,
|
||||
originalName: a.original_name,
|
||||
mimeType: a.mime_type ?? null,
|
||||
sizeBytes: a.size_bytes,
|
||||
createdAt: a.created_at,
|
||||
url: `/tickets/${ticketId}/attachments/${a.id}/file`,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapEvent(r, attachmentRows = []) {
|
||||
const list = Array.isArray(attachmentRows) ? attachmentRows : [];
|
||||
return {
|
||||
id: r.id,
|
||||
ticketId: r.ticket_id,
|
||||
type: r.type,
|
||||
description: r.description,
|
||||
createdAt: r.created_at,
|
||||
callbackNumber: r.callback_number ?? null,
|
||||
teamviewerId: r.teamviewer_id ?? null,
|
||||
articleNumber: r.article_number ?? null,
|
||||
remoteDurationSeconds:
|
||||
r.remote_duration_seconds != null ? r.remote_duration_seconds : null,
|
||||
teamviewerNotes:
|
||||
r.teamviewer_notes != null && String(r.teamviewer_notes).trim() !== ''
|
||||
? String(r.teamviewer_notes).trim()
|
||||
: null,
|
||||
attachments: list.map((a) => mapAttachmentRow(a, r.ticket_id)),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapPublicUser(r) {
|
||||
return {
|
||||
id: r.id,
|
||||
username: r.username,
|
||||
role: r.role,
|
||||
active: Boolean(r.active),
|
||||
source: r.source,
|
||||
ldapDn: r.ldap_dn || null,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** Spätester Start der Bearbeitungszeit: gespeicherter Anker oder letztes Nutzer-Event (ohne SYSTEM). */
|
||||
export const ticketSlaActivityAnchorExpr = `MAX(
|
||||
datetime(COALESCE(t.sla_anchor_at, t.created_at)),
|
||||
COALESCE(
|
||||
(SELECT MAX(datetime(e.created_at)) FROM events e
|
||||
WHERE e.ticket_id = t.id AND e.type IN ('NOTE','CALL','REMOTE','PART','ATTACHMENT')),
|
||||
datetime(COALESCE(t.sla_anchor_at, t.created_at))
|
||||
)
|
||||
)`;
|
||||
|
||||
export const ticketSlaDueExpr = `datetime((${ticketSlaActivityAnchorExpr}), '+' || CAST(COALESCE(t.sla_days, 2) AS TEXT) || ' days')`;
|
||||
|
||||
/** Spätester Zeitpunkt aus Ticket und Historie (alle Event-Typen). */
|
||||
export const ticketLastActivityExpr = `MAX(
|
||||
datetime(t.updated_at),
|
||||
COALESCE(
|
||||
(SELECT MAX(datetime(e.created_at)) FROM events e WHERE e.ticket_id = t.id),
|
||||
datetime(t.updated_at)
|
||||
)
|
||||
)`;
|
||||
|
||||
export const ticketJoinSelect = `
|
||||
SELECT t.*,
|
||||
${ticketLastActivityExpr} AS ticket_last_activity_at,
|
||||
${ticketSlaDueExpr} AS sla_due_at,
|
||||
(CASE WHEN t.status IN ('OPEN','WAITING') AND datetime('now') > ${ticketSlaDueExpr} THEN 1 ELSE 0 END) AS sla_is_overdue,
|
||||
json_object(
|
||||
'id', m.id,
|
||||
'name', m.name,
|
||||
'typ', m.typ,
|
||||
'seriennummer', m.seriennummer,
|
||||
'standort', m.standort,
|
||||
'list_status', m.list_status,
|
||||
'extras', m.extras,
|
||||
'created_at', m.created_at,
|
||||
'updated_at', m.updated_at
|
||||
) AS machine_row
|
||||
FROM tickets t
|
||||
JOIN machines m ON m.id = t.machine_id`;
|
||||
51
server/lib/merge-attachment-events.js
Normal file
51
server/lib/merge-attachment-events.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Mehrere ATTACHMENT-Events pro Ticket und Kalendertag (lokal) zu einem Event zusammenführen.
|
||||
*/
|
||||
export function mergeLegacyAttachmentEventsByDay(db) {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, ticket_id, created_at, description,
|
||||
date(created_at, 'localtime') AS day_key
|
||||
FROM events
|
||||
WHERE type = 'ATTACHMENT'
|
||||
ORDER BY ticket_id, day_key, created_at ASC`,
|
||||
)
|
||||
.all();
|
||||
|
||||
/** @type {Map<string, Array<{id: string, description: string}>>} */
|
||||
const groups = new Map();
|
||||
for (const r of rows) {
|
||||
const k = `${r.ticket_id}|${r.day_key}`;
|
||||
if (!groups.has(k)) groups.set(k, []);
|
||||
groups.get(k).push({ id: r.id, description: r.description || '' });
|
||||
}
|
||||
|
||||
for (const [, evs] of groups) {
|
||||
if (evs.length <= 1) continue;
|
||||
const keeper = evs[0].id;
|
||||
const others = evs.slice(1);
|
||||
const descParts = evs
|
||||
.map((e) => String(e.description || '').trim())
|
||||
.filter(Boolean);
|
||||
const mergedDesc = [...new Set(descParts)].join('\n\n');
|
||||
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
db.prepare('UPDATE events SET description = ? WHERE id = ?').run(
|
||||
mergedDesc,
|
||||
keeper,
|
||||
);
|
||||
for (const o of others) {
|
||||
db.prepare('UPDATE ticket_attachments SET event_id = ? WHERE event_id = ?').run(
|
||||
keeper,
|
||||
o.id,
|
||||
);
|
||||
db.prepare('DELETE FROM events WHERE id = ?').run(o.id);
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
86
server/lib/ticket-events-merge.js
Normal file
86
server/lib/ticket-events-merge.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { mapEvent } from './mappers.js';
|
||||
|
||||
/**
|
||||
* Mehrere ATTACHMENT-DB-Zeilen pro Kalendertag (lokal) zu einem Listeneintrag zusammenführen.
|
||||
* @param {Array<Record<string, unknown>>} rows – SELECT * FROM events (bereits sortiert)
|
||||
* @param {Map<string, Array<Record<string, unknown>>>} byEvent – event_id → ticket_attachments-Zeilen
|
||||
* @param {{ prepare: (sql: string) => { get: (a: unknown) => { d?: string } | undefined } }} db
|
||||
*/
|
||||
export function mergeAttachmentEventsForApi(rows, byEvent, db) {
|
||||
const dayKeyStmt = db.prepare(`SELECT date(?, 'localtime') AS d`);
|
||||
|
||||
const nonAtt = [];
|
||||
/** @type {Map<string, Array<Record<string, unknown>>>} */
|
||||
const attByDay = new Map();
|
||||
|
||||
for (const r of rows) {
|
||||
if (r.type !== 'ATTACHMENT') {
|
||||
nonAtt.push(r);
|
||||
continue;
|
||||
}
|
||||
const dk = dayKeyStmt.get(r.created_at)?.d;
|
||||
const key = dk || String(r.created_at || '').slice(0, 10);
|
||||
if (!attByDay.has(key)) attByDay.set(key, []);
|
||||
attByDay.get(key).push(r);
|
||||
}
|
||||
|
||||
/** @type {Array<{ row: Record<string, unknown>; attachments: Array<Record<string, unknown>> }>} */
|
||||
const mergedChunks = [];
|
||||
|
||||
for (const [, group] of attByDay) {
|
||||
group.sort((a, b) =>
|
||||
String(a.created_at).localeCompare(String(b.created_at)),
|
||||
);
|
||||
const maxCreated = group.reduce((best, x) =>
|
||||
String(x.created_at) > String(best) ? x.created_at : best,
|
||||
group[0].created_at,
|
||||
);
|
||||
const descs = [
|
||||
...new Set(
|
||||
group
|
||||
.map((g) => String(g.description || '').trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
const mergedRow = {
|
||||
...group[0],
|
||||
id: group[0].id,
|
||||
created_at: maxCreated,
|
||||
description: descs.join('\n\n'),
|
||||
};
|
||||
|
||||
const allAtt = [];
|
||||
for (const ev of group) {
|
||||
const list = byEvent.get(ev.id);
|
||||
if (list) allAtt.push(...list);
|
||||
}
|
||||
allAtt.sort((a, b) =>
|
||||
String(a.created_at).localeCompare(String(b.created_at)),
|
||||
);
|
||||
|
||||
mergedChunks.push({ row: mergedRow, attachments: allAtt });
|
||||
}
|
||||
|
||||
mergedChunks.sort((a, b) =>
|
||||
String(b.row.created_at).localeCompare(String(a.row.created_at)),
|
||||
);
|
||||
|
||||
nonAtt.sort((a, b) =>
|
||||
String(b.created_at).localeCompare(String(a.created_at)),
|
||||
);
|
||||
|
||||
const out = [
|
||||
...nonAtt,
|
||||
...mergedChunks.map((c) => c.row),
|
||||
];
|
||||
|
||||
const mergedByEventId = new Map(
|
||||
mergedChunks.map((c) => [c.row.id, c.attachments]),
|
||||
);
|
||||
|
||||
return out.map((r) =>
|
||||
r.type === 'ATTACHMENT'
|
||||
? mapEvent(r, mergedByEventId.get(r.id) || [])
|
||||
: mapEvent(r, []),
|
||||
);
|
||||
}
|
||||
22
server/middleware/auth.js
Normal file
22
server/middleware/auth.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import db from '../db.js';
|
||||
|
||||
export function requireAuth(req, res, next) {
|
||||
if (!req.session?.userId) {
|
||||
return res.status(401).json({ message: 'Nicht angemeldet' });
|
||||
}
|
||||
const u = db
|
||||
.prepare('SELECT id, active FROM users WHERE id = ?')
|
||||
.get(req.session.userId);
|
||||
if (!u || !u.active) {
|
||||
req.session.destroy(() => {});
|
||||
return res.status(401).json({ message: 'Nicht angemeldet' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireAdmin(req, res, next) {
|
||||
if (req.session?.role !== 'admin') {
|
||||
return res.status(403).json({ message: 'Administratorrechte erforderlich.' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
173
server/routes/admin/index.js
Normal file
173
server/routes/admin/index.js
Normal file
@@ -0,0 +1,173 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Router } from 'express';
|
||||
import db from '../../db.js';
|
||||
import { getSyncStatus, performLdapSync } from '../../ldap-sync.js';
|
||||
import { badRequest, UUID } from '../../lib/http.js';
|
||||
import { mapPublicUser } from '../../lib/mappers.js';
|
||||
import {
|
||||
loadIntegrations,
|
||||
restartLdapSyncScheduler,
|
||||
saveIntegrations,
|
||||
} from '../../integrations.js';
|
||||
import { hashPassword } from '../../password.js';
|
||||
import { requireAdmin, requireAuth } from '../../middleware/auth.js';
|
||||
|
||||
export function createAdminRouter() {
|
||||
const admin = Router();
|
||||
admin.use(requireAuth, requireAdmin);
|
||||
|
||||
admin.get('/users', (_req, res) => {
|
||||
const rows = db
|
||||
.prepare(
|
||||
'SELECT id, username, role, source, active, ldap_dn, created_at, updated_at FROM users ORDER BY username ASC',
|
||||
)
|
||||
.all();
|
||||
res.json(rows.map(mapPublicUser));
|
||||
});
|
||||
|
||||
admin.post('/users', async (req, res) => {
|
||||
const { username, password, role } = req.body || {};
|
||||
const un = String(username || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!un || !password) return badRequest(res, 'Benutzername und Passwort erforderlich.');
|
||||
const r = role === 'admin' ? 'admin' : 'user';
|
||||
const id = randomUUID();
|
||||
const ph = await hashPassword(password);
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, username, password_hash, role, source, active, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'local', 1, datetime('now'))`,
|
||||
).run(id, un, ph, r);
|
||||
} catch (e) {
|
||||
if (String(e.message || e).includes('UNIQUE')) {
|
||||
return res.status(409).json({ message: 'Benutzername bereits vergeben.' });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||
res.status(201).json(mapPublicUser(row));
|
||||
});
|
||||
|
||||
admin.put('/users/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const cur = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||
if (!cur) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const b = req.body || {};
|
||||
|
||||
if (b.password != null && String(b.password).length > 0) {
|
||||
if (cur.source !== 'local') {
|
||||
return badRequest(res, 'Passwort nur für lokale Benutzer änderbar.');
|
||||
}
|
||||
const ph = await hashPassword(b.password);
|
||||
db.prepare(
|
||||
'UPDATE users SET password_hash = ?, updated_at = datetime(\'now\') WHERE id = ?',
|
||||
).run(ph, id);
|
||||
}
|
||||
if (b.role !== undefined) {
|
||||
if (b.role !== 'admin' && b.role !== 'user') {
|
||||
return badRequest(res, 'Ungültige Rolle.');
|
||||
}
|
||||
const admins = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND active = 1",
|
||||
)
|
||||
.get().c;
|
||||
if (cur.role === 'admin' && b.role === 'user' && admins <= 1) {
|
||||
return res.status(400).json({ message: 'Letzter Administrator kann nicht herabgestuft werden.' });
|
||||
}
|
||||
db.prepare('UPDATE users SET role = ?, updated_at = datetime(\'now\') WHERE id = ?').run(
|
||||
b.role,
|
||||
id,
|
||||
);
|
||||
}
|
||||
if (b.active !== undefined) {
|
||||
const active = b.active ? 1 : 0;
|
||||
const admins = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND active = 1",
|
||||
)
|
||||
.get().c;
|
||||
if (cur.role === 'admin' && cur.active && !active && admins <= 1) {
|
||||
return res.status(400).json({ message: 'Letzter Administrator kann nicht deaktiviert werden.' });
|
||||
}
|
||||
db.prepare('UPDATE users SET active = ?, updated_at = datetime(\'now\') WHERE id = ?').run(
|
||||
active,
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||
res.json(mapPublicUser(row));
|
||||
});
|
||||
|
||||
admin.delete('/users/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
if (id === req.session.userId) {
|
||||
return res.status(400).json({ message: 'Eigenes Konto kann nicht gelöscht werden.' });
|
||||
}
|
||||
const cur = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||
if (!cur) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
if (cur.role === 'admin') {
|
||||
const admins = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND active = 1",
|
||||
)
|
||||
.get().c;
|
||||
if (admins <= 1) {
|
||||
return res.status(400).json({ message: 'Letzter Administrator kann nicht gelöscht werden.' });
|
||||
}
|
||||
}
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
admin.get('/settings/integrations', (_req, res) => {
|
||||
res.json(loadIntegrations());
|
||||
});
|
||||
|
||||
admin.put('/settings/integrations', (req, res) => {
|
||||
const b = req.body || {};
|
||||
const cur = loadIntegrations();
|
||||
if (b.ldap && typeof b.ldap === 'object') {
|
||||
const incoming = { ...b.ldap };
|
||||
if (incoming.bindPassword === '' || incoming.bindPassword == null) {
|
||||
delete incoming.bindPassword;
|
||||
}
|
||||
if (incoming.syncIntervalMinutes != null) {
|
||||
const n = Number(incoming.syncIntervalMinutes);
|
||||
incoming.syncIntervalMinutes = Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 1440;
|
||||
}
|
||||
Object.assign(cur.ldap, incoming);
|
||||
if (b.ldap.userSearchFilter != null) {
|
||||
cur.ldap.userFilter = String(b.ldap.userSearchFilter);
|
||||
}
|
||||
}
|
||||
if (b.teamviewer && typeof b.teamviewer === 'object') {
|
||||
const inc = { ...b.teamviewer };
|
||||
if (inc.bearerToken === '' || inc.bearerToken == null) {
|
||||
delete inc.bearerToken;
|
||||
}
|
||||
Object.assign(cur.teamviewer, inc);
|
||||
}
|
||||
saveIntegrations(cur);
|
||||
restartLdapSyncScheduler();
|
||||
res.json(loadIntegrations());
|
||||
});
|
||||
|
||||
admin.get('/ldap/sync-status', (_req, res) => {
|
||||
res.json(getSyncStatus(db));
|
||||
});
|
||||
|
||||
admin.post('/ldap/sync', async (_req, res) => {
|
||||
const r = await performLdapSync(db, loadIntegrations, 'manual');
|
||||
if (r.skipped) {
|
||||
return res.status(409).json({ message: r.message });
|
||||
}
|
||||
res.json(r);
|
||||
});
|
||||
|
||||
return admin;
|
||||
}
|
||||
304
server/routes/api/attachments.js
Normal file
304
server/routes/api/attachments.js
Normal file
@@ -0,0 +1,304 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
import multer from 'multer';
|
||||
import db from '../../db.js';
|
||||
import { badRequest, UUID } from '../../lib/http.js';
|
||||
import { mapEvent } from '../../lib/mappers.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Beschreibungstexte beim Zusammenführen (ohne doppelte Blöcke). */
|
||||
function mergeDescriptions(existing, incoming) {
|
||||
const a = String(existing || '').trim();
|
||||
const b = String(incoming || '').trim();
|
||||
if (!b) return a;
|
||||
if (!a) return b;
|
||||
if (a.includes(b)) return a;
|
||||
return `${a}\n\n${b}`;
|
||||
}
|
||||
const uploadsRoot = path.resolve(
|
||||
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', 'data', 'uploads'),
|
||||
);
|
||||
|
||||
const maxFileSize = Number(process.env.ATTACHMENT_MAX_BYTES) || 20 * 1024 * 1024;
|
||||
const maxFiles = Number(process.env.ATTACHMENT_MAX_FILES) || 20;
|
||||
|
||||
function safeBasename(name) {
|
||||
let base = path.basename(name || 'file');
|
||||
base = base.replace(/[/\\?%*:|"<>]/g, '_');
|
||||
return base.slice(0, 200) || 'file';
|
||||
}
|
||||
|
||||
function handleMulterError(err, res) {
|
||||
if (err?.name === 'MulterError') {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ message: 'Datei zu groß.' });
|
||||
}
|
||||
if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
|
||||
return res.status(400).json({ message: 'Zu viele Dateien.' });
|
||||
}
|
||||
return res.status(400).json({ message: err.message || 'Upload fehlgeschlagen.' });
|
||||
}
|
||||
return res.status(400).json({ message: err.message || 'Upload fehlgeschlagen.' });
|
||||
}
|
||||
|
||||
/** Multer 2.x: kein diskStorage — Dateien liegen temporär vor, Zugriff über `stream`. */
|
||||
function uploadMiddleware(req, res, next) {
|
||||
const { ticketId } = req.params;
|
||||
if (!UUID.test(ticketId)) {
|
||||
return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
}
|
||||
const upload = multer({
|
||||
limits: { fileSize: maxFileSize, files: maxFiles },
|
||||
}).array('files', maxFiles);
|
||||
|
||||
upload(req, res, (err) => {
|
||||
if (err) return handleMulterError(err, res);
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
/** Streams nach `data/uploads/tickets/<ticketId>/` schreiben (permanent). */
|
||||
async function persistUploadedFiles(files, ticketId) {
|
||||
const destDir = path.join(uploadsRoot, 'tickets', ticketId);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const saved = [];
|
||||
for (const f of files) {
|
||||
const filename = `${randomUUID()}_${safeBasename(f.originalName)}`;
|
||||
const absPath = path.join(destDir, filename);
|
||||
await pipeline(f.stream, fs.createWriteStream(absPath));
|
||||
const stat = fs.statSync(absPath);
|
||||
saved.push({
|
||||
path: absPath,
|
||||
originalname: f.originalName || 'Datei',
|
||||
mimetype: f.clientReportedMimeType,
|
||||
size: stat.size,
|
||||
});
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
function unlinkUploaded(files) {
|
||||
for (const f of files || []) {
|
||||
try {
|
||||
if (f?.path) fs.unlinkSync(f.path);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Temporäre Multer-Dateien verwerfen, wenn kein persist erfolgt. */
|
||||
function discardIncomingFiles(files) {
|
||||
for (const f of files || []) {
|
||||
try {
|
||||
if (f.stream && typeof f.stream.destroy === 'function') f.stream.destroy();
|
||||
if (f.path && fs.existsSync(f.path)) fs.unlinkSync(f.path);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAttachmentRoutes(api) {
|
||||
api.post(
|
||||
'/tickets/:ticketId/events/attachments',
|
||||
uploadMiddleware,
|
||||
async (req, res) => {
|
||||
const { ticketId } = req.params;
|
||||
const incoming = req.files || [];
|
||||
if (incoming.length === 0) {
|
||||
return badRequest(res, 'Mindestens eine Datei erforderlich.');
|
||||
}
|
||||
|
||||
const t = db
|
||||
.prepare('SELECT 1 AS ok FROM tickets WHERE id = ?')
|
||||
.get(ticketId);
|
||||
if (!t) {
|
||||
discardIncomingFiles(incoming);
|
||||
return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
}
|
||||
|
||||
const descRaw =
|
||||
req.body && req.body.description != null
|
||||
? String(req.body.description).trim()
|
||||
: '';
|
||||
const description = descRaw || '';
|
||||
|
||||
let saved;
|
||||
try {
|
||||
saved = await persistUploadedFiles(incoming, ticketId);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return res.status(500).json({ message: 'Dateien konnten nicht gespeichert werden.' });
|
||||
}
|
||||
|
||||
const existing = db
|
||||
.prepare(
|
||||
`SELECT id, description FROM events
|
||||
WHERE ticket_id = ? AND type = 'ATTACHMENT'
|
||||
AND date(created_at, 'localtime') = date('now', 'localtime')
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(ticketId);
|
||||
|
||||
let eid;
|
||||
let createdNewEvent = false;
|
||||
|
||||
try {
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
if (existing) {
|
||||
eid = existing.id;
|
||||
const mergedDesc = mergeDescriptions(existing.description, description);
|
||||
db.prepare('UPDATE events SET description = ? WHERE id = ?').run(
|
||||
mergedDesc,
|
||||
eid,
|
||||
);
|
||||
} else {
|
||||
eid = randomUUID();
|
||||
createdNewEvent = true;
|
||||
db.prepare(
|
||||
`INSERT INTO events (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds, teamviewer_notes)
|
||||
VALUES (?, ?, 'ATTACHMENT', ?, NULL, NULL, NULL, NULL, NULL)`,
|
||||
).run(eid, ticketId, description);
|
||||
}
|
||||
|
||||
const insAtt = db.prepare(
|
||||
`INSERT INTO ticket_attachments (id, event_id, original_name, stored_path, mime_type, size_bytes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
|
||||
for (const f of saved) {
|
||||
const aid = randomUUID();
|
||||
const rel = path
|
||||
.relative(uploadsRoot, f.path)
|
||||
.split(path.sep)
|
||||
.join('/');
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
throw new Error('Ungültiger Speicherpfad');
|
||||
}
|
||||
const mime =
|
||||
f.mimetype && String(f.mimetype).trim()
|
||||
? String(f.mimetype).trim()
|
||||
: null;
|
||||
insAtt.run(
|
||||
aid,
|
||||
eid,
|
||||
f.originalname,
|
||||
rel,
|
||||
mime,
|
||||
f.size,
|
||||
);
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE tickets SET updated_at = datetime('now'),
|
||||
sla_anchor_at = CASE WHEN status IN ('OPEN', 'WAITING') THEN datetime('now') ELSE sla_anchor_at END
|
||||
WHERE id = ?`,
|
||||
).run(ticketId);
|
||||
|
||||
db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
|
||||
const row = db.prepare('SELECT * FROM events WHERE id = ?').get(eid);
|
||||
const attRows = db
|
||||
.prepare(
|
||||
'SELECT * FROM ticket_attachments WHERE event_id = ? ORDER BY created_at ASC',
|
||||
)
|
||||
.all(eid);
|
||||
res
|
||||
.status(createdNewEvent ? 201 : 200)
|
||||
.json(mapEvent(row, attRows));
|
||||
} catch (e) {
|
||||
unlinkUploaded(saved);
|
||||
console.error(e);
|
||||
return res.status(500).json({ message: 'Speichern fehlgeschlagen.' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
api.get('/tickets/:ticketId/attachments/:attachmentId/file', (req, res) => {
|
||||
const { ticketId, attachmentId } = req.params;
|
||||
if (!UUID.test(ticketId) || !UUID.test(attachmentId)) {
|
||||
return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
}
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT ta.id, ta.original_name, ta.stored_path, e.ticket_id AS ev_tid
|
||||
FROM ticket_attachments ta
|
||||
JOIN events e ON e.id = ta.event_id
|
||||
WHERE ta.id = ? AND e.ticket_id = ?`,
|
||||
)
|
||||
.get(attachmentId, ticketId);
|
||||
if (!row) {
|
||||
return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
}
|
||||
if (String(row.stored_path).includes('..')) {
|
||||
return res.status(400).json({ message: 'Ungültiger Pfad' });
|
||||
}
|
||||
const abs = path.resolve(path.join(uploadsRoot, row.stored_path));
|
||||
const rootResolved = path.resolve(uploadsRoot);
|
||||
if (abs !== rootResolved && !abs.startsWith(`${rootResolved}${path.sep}`)) {
|
||||
return res.status(400).json({ message: 'Ungültiger Pfad' });
|
||||
}
|
||||
if (!fs.existsSync(abs)) {
|
||||
return res.status(404).json({ message: 'Datei fehlt' });
|
||||
}
|
||||
|
||||
const inline = req.query.inline === '1' || req.query.inline === 'true';
|
||||
let mime =
|
||||
row.mime_type && String(row.mime_type).trim()
|
||||
? String(row.mime_type).trim()
|
||||
: null;
|
||||
if (!mime) {
|
||||
mime = guessMimeFromFilename(row.original_name);
|
||||
}
|
||||
|
||||
if (inline) {
|
||||
res.setHeader('Content-Type', mime);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`inline; filename*=UTF-8''${encodeURIComponent(row.original_name)}`,
|
||||
);
|
||||
return res.sendFile(abs);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', mime);
|
||||
res.download(abs, row.original_name);
|
||||
});
|
||||
}
|
||||
|
||||
function guessMimeFromFilename(name) {
|
||||
const ext = path.extname(name || '').toLowerCase();
|
||||
const map = {
|
||||
'.pdf': 'application/pdf',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.bmp': 'image/bmp',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.csv': 'text/csv; charset=utf-8',
|
||||
'.json': 'application/json',
|
||||
'.xml': 'application/xml',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.htm': 'text/html; charset=utf-8',
|
||||
'.md': 'text/markdown; charset=utf-8',
|
||||
'.webm': 'video/webm',
|
||||
'.mp4': 'video/mp4',
|
||||
'.ogg': 'video/ogg',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
};
|
||||
return map[ext] || 'application/octet-stream';
|
||||
}
|
||||
104
server/routes/api/events.js
Normal file
104
server/routes/api/events.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import db from '../../db.js';
|
||||
import { badRequest } from '../../lib/http.js';
|
||||
import { mapEvent } from '../../lib/mappers.js';
|
||||
import { computeRemoteDurationSeconds } from '../../teamviewer.js';
|
||||
|
||||
const EVENT_TYPES_USER = new Set(['NOTE', 'CALL', 'REMOTE', 'PART']);
|
||||
|
||||
export function registerEventRoutes(api) {
|
||||
api.post('/events', (req, res) => {
|
||||
const b = req.body || {};
|
||||
const ticketId = b.ticketId;
|
||||
const type = b.type;
|
||||
if (!ticketId || !type || !EVENT_TYPES_USER.has(type)) {
|
||||
return badRequest(res, 'Pflichtfelder fehlen oder ungültiger Typ.');
|
||||
}
|
||||
const t = db
|
||||
.prepare('SELECT 1 AS ok FROM tickets WHERE id = ?')
|
||||
.get(ticketId);
|
||||
if (!t) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
|
||||
const desc = b.description != null ? String(b.description).trim() : '';
|
||||
const callbackNumber =
|
||||
b.callbackNumber != null ? String(b.callbackNumber).trim() : '';
|
||||
const teamviewerId =
|
||||
b.teamviewerId != null ? String(b.teamviewerId).trim() : '';
|
||||
const articleNumber =
|
||||
b.articleNumber != null ? String(b.articleNumber).trim() : '';
|
||||
|
||||
let description = desc;
|
||||
let cb = callbackNumber || null;
|
||||
let tv = teamviewerId || null;
|
||||
let art = articleNumber || null;
|
||||
|
||||
if (type === 'NOTE') {
|
||||
if (!description) return badRequest(res, 'Beschreibung fehlt.');
|
||||
cb = null;
|
||||
tv = null;
|
||||
art = null;
|
||||
} else if (type === 'CALL') {
|
||||
// Rückrufnummer optional; nur Beschreibung ist Pflicht.
|
||||
if (!description) return badRequest(res, 'Beschreibung fehlt.');
|
||||
tv = null;
|
||||
art = null;
|
||||
} else if (type === 'REMOTE') {
|
||||
if (!description?.trim() && !tv) {
|
||||
return badRequest(res, 'Beschreibung oder TeamViewer-Gerät erforderlich.');
|
||||
}
|
||||
cb = null;
|
||||
art = null;
|
||||
} else if (type === 'PART') {
|
||||
if (!articleNumber) return badRequest(res, 'Artikelnummer fehlt.');
|
||||
description = desc;
|
||||
cb = null;
|
||||
tv = null;
|
||||
}
|
||||
|
||||
let remoteDurationSeconds = null;
|
||||
let teamviewerNotes = null;
|
||||
if (type === 'REMOTE') {
|
||||
remoteDurationSeconds = computeRemoteDurationSeconds(
|
||||
b.teamviewerStartDate,
|
||||
b.teamviewerEndDate,
|
||||
);
|
||||
const tn =
|
||||
b.teamviewerNotes != null ? String(b.teamviewerNotes).trim() : '';
|
||||
teamviewerNotes = tn || null;
|
||||
}
|
||||
|
||||
const eid = randomUUID();
|
||||
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
const row = db
|
||||
.prepare(
|
||||
`INSERT INTO events (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds, teamviewer_notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
|
||||
)
|
||||
.get(
|
||||
eid,
|
||||
ticketId,
|
||||
type,
|
||||
description,
|
||||
cb,
|
||||
tv,
|
||||
art,
|
||||
remoteDurationSeconds,
|
||||
teamviewerNotes,
|
||||
);
|
||||
|
||||
db.prepare(
|
||||
`UPDATE tickets SET updated_at = datetime('now'),
|
||||
sla_anchor_at = CASE WHEN status IN ('OPEN', 'WAITING') THEN datetime('now') ELSE sla_anchor_at END
|
||||
WHERE id = ?`,
|
||||
).run(ticketId);
|
||||
|
||||
db.exec('COMMIT');
|
||||
res.status(201).json(mapEvent(row));
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
19
server/routes/api/index.js
Normal file
19
server/routes/api/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Router } from 'express';
|
||||
import { loadIntegrations } from '../../integrations.js';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { registerTeamViewerRoutes } from '../../teamviewer.js';
|
||||
import { registerAttachmentRoutes } from './attachments.js';
|
||||
import { registerEventRoutes } from './events.js';
|
||||
import { registerMachineRoutes } from './machines.js';
|
||||
import { registerTicketRoutes } from './tickets.js';
|
||||
|
||||
export function createApiRouter() {
|
||||
const api = Router();
|
||||
api.use(requireAuth);
|
||||
registerMachineRoutes(api);
|
||||
registerTicketRoutes(api);
|
||||
registerAttachmentRoutes(api);
|
||||
registerTeamViewerRoutes(api, loadIntegrations);
|
||||
registerEventRoutes(api);
|
||||
return api;
|
||||
}
|
||||
144
server/routes/api/machines.js
Normal file
144
server/routes/api/machines.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import db from '../../db.js';
|
||||
import { badRequest, UUID } from '../../lib/http.js';
|
||||
import { mapMachine } from '../../lib/mappers.js';
|
||||
|
||||
const ALLOWED_LIST_STATUS = new Set([
|
||||
'',
|
||||
'PRUEFEN',
|
||||
'VERSCHROTTET',
|
||||
'SN_GEAENDERT',
|
||||
'IN_BEARBEITUNG',
|
||||
'UPDATE_RAUS',
|
||||
]);
|
||||
|
||||
function normalizeListStatus(v) {
|
||||
if (v == null || v === '') return '';
|
||||
const s = String(v).trim();
|
||||
return ALLOWED_LIST_STATUS.has(s) ? s : null;
|
||||
}
|
||||
|
||||
/** @returns {{ ok: true, json: string | null } | { ok: false, message: string }} */
|
||||
function parseExtrasField(extras) {
|
||||
if (extras === null || extras === '') {
|
||||
return { ok: true, json: null };
|
||||
}
|
||||
if (typeof extras === 'object' && extras !== null) {
|
||||
try {
|
||||
return { ok: true, json: JSON.stringify(extras) };
|
||||
} catch {
|
||||
return { ok: false, message: 'extras ist kein gültiges JSON-Objekt.' };
|
||||
}
|
||||
}
|
||||
if (typeof extras === 'string') {
|
||||
try {
|
||||
JSON.parse(extras);
|
||||
return { ok: true, json: extras };
|
||||
} catch {
|
||||
return { ok: false, message: 'extras ist kein gültiger JSON-String.' };
|
||||
}
|
||||
}
|
||||
return { ok: false, message: 'extras hat ein ungültiges Format.' };
|
||||
}
|
||||
|
||||
export function registerMachineRoutes(api) {
|
||||
api.get('/machines', (_req, res) => {
|
||||
const rows = db
|
||||
.prepare('SELECT * FROM machines ORDER BY seriennummer ASC')
|
||||
.all();
|
||||
res.json(rows.map(mapMachine));
|
||||
});
|
||||
|
||||
api.post('/machines', (req, res) => {
|
||||
const b = req.body || {};
|
||||
const { name, typ, seriennummer, standort, listStatus } = b;
|
||||
if (!name || !typ || !seriennummer || !standort) {
|
||||
return badRequest(res, 'Pflichtfelder fehlen.');
|
||||
}
|
||||
const ls = normalizeListStatus(listStatus);
|
||||
if (ls === null) return badRequest(res, 'Ungültiger Listen-Status.');
|
||||
let extrasJson = null;
|
||||
if (Object.prototype.hasOwnProperty.call(b, 'extras')) {
|
||||
const p = parseExtrasField(b.extras);
|
||||
if (!p.ok) return badRequest(res, p.message);
|
||||
extrasJson = p.json;
|
||||
}
|
||||
const id = randomUUID();
|
||||
const row = db
|
||||
.prepare(
|
||||
`INSERT INTO machines (id, name, typ, seriennummer, standort, list_status, extras, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) RETURNING *`,
|
||||
)
|
||||
.get(id, name, typ, seriennummer, standort, ls, extrasJson);
|
||||
res.status(201).json(mapMachine(row));
|
||||
});
|
||||
|
||||
api.get('/machines/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const row = db.prepare('SELECT * FROM machines WHERE id = ?').get(id);
|
||||
if (!row) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
res.json(mapMachine(row));
|
||||
});
|
||||
|
||||
api.put('/machines/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const cur = db.prepare('SELECT * FROM machines WHERE id = ?').get(id);
|
||||
if (!cur) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const b = req.body || {};
|
||||
const next = {
|
||||
name: b.name ?? cur.name,
|
||||
typ: b.typ ?? cur.typ,
|
||||
seriennummer: b.seriennummer ?? cur.seriennummer,
|
||||
standort: b.standort ?? cur.standort,
|
||||
};
|
||||
let listStatusVal = cur.list_status ?? '';
|
||||
if (Object.prototype.hasOwnProperty.call(b, 'listStatus')) {
|
||||
const ls = normalizeListStatus(b.listStatus);
|
||||
if (ls === null) return badRequest(res, 'Ungültiger Listen-Status.');
|
||||
listStatusVal = ls;
|
||||
}
|
||||
let extrasJson = cur.extras;
|
||||
if (Object.prototype.hasOwnProperty.call(b, 'extras')) {
|
||||
const p = parseExtrasField(b.extras);
|
||||
if (!p.ok) return badRequest(res, p.message);
|
||||
extrasJson = p.json;
|
||||
}
|
||||
const row = db
|
||||
.prepare(
|
||||
`UPDATE machines SET name = ?, typ = ?, seriennummer = ?, standort = ?, list_status = ?, extras = ?, updated_at = datetime('now')
|
||||
WHERE id = ? RETURNING *`,
|
||||
)
|
||||
.get(
|
||||
next.name,
|
||||
next.typ,
|
||||
next.seriennummer,
|
||||
next.standort,
|
||||
listStatusVal,
|
||||
extrasJson,
|
||||
id,
|
||||
);
|
||||
res.json(mapMachine(row));
|
||||
});
|
||||
|
||||
api.delete('/machines/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const cur = db.prepare('SELECT * FROM machines WHERE id = ?').get(id);
|
||||
if (!cur) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const tc = db
|
||||
.prepare('SELECT COUNT(*) AS c FROM tickets WHERE machine_id = ?')
|
||||
.get(id);
|
||||
if (tc.c > 0) {
|
||||
return res.status(409).json({
|
||||
message:
|
||||
'Maschine kann nicht gelöscht werden: Es existieren noch zugeordnete Tickets.',
|
||||
});
|
||||
}
|
||||
const row = db
|
||||
.prepare('DELETE FROM machines WHERE id = ? RETURNING *')
|
||||
.get(id);
|
||||
res.json(mapMachine(row));
|
||||
});
|
||||
}
|
||||
202
server/routes/api/tickets.js
Normal file
202
server/routes/api/tickets.js
Normal file
@@ -0,0 +1,202 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import db from '../../db.js';
|
||||
import { badRequest, UUID } from '../../lib/http.js';
|
||||
import { mergeAttachmentEventsForApi } from '../../lib/ticket-events-merge.js';
|
||||
import {
|
||||
mapTicket,
|
||||
ticketJoinSelect,
|
||||
ticketLastActivityExpr,
|
||||
ticketSlaDueExpr,
|
||||
} from '../../lib/mappers.js';
|
||||
|
||||
function parseSlaDaysForCreate(v) {
|
||||
if (v === undefined || v === null || v === '') return null;
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 5) return undefined;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Nur wenn Key gesetzt: null = Standard (2 Tage), 1–5 = Tage. */
|
||||
function parseSlaDaysForUpdate(v) {
|
||||
if (v === null || v === '') return null;
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 5) return undefined;
|
||||
return n;
|
||||
}
|
||||
|
||||
function slaDaysEqual(a, b) {
|
||||
const na = a == null || a === '' ? null : Number(a);
|
||||
const nb = b == null || b === '' ? null : Number(b);
|
||||
return na === nb;
|
||||
}
|
||||
|
||||
const ticketListOrderBy = `
|
||||
ORDER BY
|
||||
CASE WHEN t.status IN ('OPEN','WAITING') AND datetime('now') > ${ticketSlaDueExpr} THEN 0 ELSE 1 END ASC,
|
||||
CASE WHEN t.status IN ('OPEN','WAITING') AND datetime('now') > ${ticketSlaDueExpr} THEN ${ticketSlaDueExpr} ELSE '9999-12-31' END ASC,
|
||||
${ticketLastActivityExpr} DESC`;
|
||||
|
||||
export function registerTicketRoutes(api) {
|
||||
api.get('/tickets', (req, res) => {
|
||||
const { status, priority, machineId, open } = req.query;
|
||||
const cond = ['1=1'];
|
||||
const params = [];
|
||||
const openFilter = open === '1' || open === 'true';
|
||||
if (openFilter) {
|
||||
cond.push("t.status IN ('OPEN', 'WAITING')");
|
||||
} else if (status) {
|
||||
cond.push('t.status = ?');
|
||||
params.push(status);
|
||||
}
|
||||
if (priority) {
|
||||
cond.push('t.priority = ?');
|
||||
params.push(priority);
|
||||
}
|
||||
if (machineId) {
|
||||
cond.push('t.machine_id = ?');
|
||||
params.push(machineId);
|
||||
}
|
||||
const sql = `${ticketJoinSelect} WHERE ${cond.join(' AND ')} ${ticketListOrderBy}`;
|
||||
const rows = db.prepare(sql).all(...params);
|
||||
res.json(rows.map(mapTicket));
|
||||
});
|
||||
|
||||
api.post('/tickets', (req, res) => {
|
||||
const { machineId, title, description, status, priority, slaDays } =
|
||||
req.body || {};
|
||||
if (!machineId || !title || !description) {
|
||||
return badRequest(res, 'Pflichtfelder fehlen.');
|
||||
}
|
||||
const m = db
|
||||
.prepare('SELECT 1 AS ok FROM machines WHERE id = ?')
|
||||
.get(machineId);
|
||||
if (!m) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const st = status || 'OPEN';
|
||||
const pr = priority || 'MEDIUM';
|
||||
const sd = parseSlaDaysForCreate(slaDays);
|
||||
if (sd === undefined) {
|
||||
return badRequest(res, 'slaDays ungültig (1–5 oder weglassen für Standard).');
|
||||
}
|
||||
const tid = randomUUID();
|
||||
db.prepare(
|
||||
`INSERT INTO tickets (id, machine_id, title, description, status, priority, sla_days, sla_anchor_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`,
|
||||
).run(tid, machineId, title, description, st, pr, sd);
|
||||
const full = db
|
||||
.prepare(`${ticketJoinSelect} WHERE t.id = ?`)
|
||||
.get(tid);
|
||||
res.status(201).json(mapTicket(full));
|
||||
});
|
||||
|
||||
api.get('/tickets/:id/events', (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const ex = db.prepare('SELECT 1 AS ok FROM tickets WHERE id = ?').get(id);
|
||||
if (!ex) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT * FROM events WHERE ticket_id = ?
|
||||
ORDER BY CASE WHEN type = 'ATTACHMENT' THEN 1 ELSE 0 END ASC,
|
||||
created_at DESC`,
|
||||
)
|
||||
.all(id);
|
||||
const eventIds = rows.map((r) => r.id);
|
||||
const byEvent = new Map();
|
||||
if (eventIds.length > 0) {
|
||||
const ph = eventIds.map(() => '?').join(',');
|
||||
const attRows = db
|
||||
.prepare(
|
||||
`SELECT * FROM ticket_attachments WHERE event_id IN (${ph}) ORDER BY created_at ASC`,
|
||||
)
|
||||
.all(...eventIds);
|
||||
for (const a of attRows) {
|
||||
if (!byEvent.has(a.event_id)) byEvent.set(a.event_id, []);
|
||||
byEvent.get(a.event_id).push(a);
|
||||
}
|
||||
}
|
||||
res.json(mergeAttachmentEventsForApi(rows, byEvent, db));
|
||||
});
|
||||
|
||||
api.get('/tickets/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const row = db.prepare(`${ticketJoinSelect} WHERE t.id = ?`).get(id);
|
||||
if (!row) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
res.json(mapTicket(row));
|
||||
});
|
||||
|
||||
api.put('/tickets/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const cur = db.prepare('SELECT * FROM tickets WHERE id = ?').get(id);
|
||||
if (!cur) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
const b = req.body || {};
|
||||
const next = {
|
||||
title: b.title ?? cur.title,
|
||||
description: b.description ?? cur.description,
|
||||
status: b.status ?? cur.status,
|
||||
priority: b.priority ?? cur.priority,
|
||||
};
|
||||
|
||||
let nextSlaDays = cur.sla_days != null ? cur.sla_days : null;
|
||||
let resetSlaAnchor = false;
|
||||
if (Object.prototype.hasOwnProperty.call(b, 'slaDays')) {
|
||||
const parsed = parseSlaDaysForUpdate(b.slaDays);
|
||||
if (parsed === undefined) {
|
||||
return badRequest(
|
||||
res,
|
||||
'slaDays ungültig (1–5 oder leer für Standard).',
|
||||
);
|
||||
}
|
||||
nextSlaDays = parsed;
|
||||
resetSlaAnchor = !slaDaysEqual(parsed, cur.sla_days);
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
if (b.status !== undefined && b.status !== cur.status) {
|
||||
lines.push(`Status: ${cur.status} → ${b.status}`);
|
||||
}
|
||||
if (b.priority !== undefined && b.priority !== cur.priority) {
|
||||
lines.push(`Priorität: ${cur.priority} → ${b.priority}`);
|
||||
}
|
||||
if (b.title !== undefined && b.title !== cur.title) lines.push('Titel geändert');
|
||||
if (b.description !== undefined && b.description !== cur.description) {
|
||||
lines.push('Beschreibung geändert');
|
||||
}
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(b, 'slaDays') &&
|
||||
!slaDaysEqual(nextSlaDays, cur.sla_days)
|
||||
) {
|
||||
const label = (d) =>
|
||||
d == null ? 'Standard (2 Tage)' : `${d} Tag(e)`;
|
||||
lines.push(
|
||||
`Fälligkeit: ${label(cur.sla_days)} → ${label(nextSlaDays)}`,
|
||||
);
|
||||
}
|
||||
if (lines.length > 0) {
|
||||
const eid = randomUUID();
|
||||
db.prepare(
|
||||
`INSERT INTO events (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds, teamviewer_notes)
|
||||
VALUES (?, ?, 'SYSTEM', ?, NULL, NULL, NULL, NULL, NULL)`,
|
||||
).run(eid, id, lines.join('; '));
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE tickets SET title = ?, description = ?, status = ?, priority = ?, sla_days = ?,
|
||||
sla_anchor_at = CASE WHEN ? THEN datetime('now') ELSE sla_anchor_at END,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
next.title,
|
||||
next.description,
|
||||
next.status,
|
||||
next.priority,
|
||||
nextSlaDays,
|
||||
resetSlaAnchor ? 1 : 0,
|
||||
id,
|
||||
);
|
||||
|
||||
const row = db.prepare(`${ticketJoinSelect} WHERE t.id = ?`).get(id);
|
||||
res.json(mapTicket(row));
|
||||
});
|
||||
}
|
||||
92
server/routes/auth.js
Normal file
92
server/routes/auth.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Router } from 'express';
|
||||
import db from '../db.js';
|
||||
import { badRequest } from '../lib/http.js';
|
||||
import { hashPassword, verifyPassword } from '../password.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/status', (req, res) => {
|
||||
const count = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
|
||||
const needsBootstrap = count === 0;
|
||||
if (needsBootstrap) {
|
||||
return res.json({ needsBootstrap: true, loggedIn: false, user: null });
|
||||
}
|
||||
if (!req.session?.userId) {
|
||||
return res.json({ needsBootstrap: false, loggedIn: false, user: null });
|
||||
}
|
||||
const u = db
|
||||
.prepare('SELECT id, username, role, active FROM users WHERE id = ?')
|
||||
.get(req.session.userId);
|
||||
if (!u || !u.active) {
|
||||
req.session.destroy(() => {});
|
||||
return res.json({ needsBootstrap: false, loggedIn: false, user: null });
|
||||
}
|
||||
res.json({
|
||||
needsBootstrap: false,
|
||||
loggedIn: true,
|
||||
user: { id: u.id, username: u.username, role: u.role },
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/bootstrap', async (req, res) => {
|
||||
const count = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
|
||||
if (count > 0) {
|
||||
return res.status(403).json({ message: 'Initialisierung nicht mehr möglich.' });
|
||||
}
|
||||
const { username, password } = req.body || {};
|
||||
const un = String(username || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!un || !password || password.length < 8) {
|
||||
return badRequest(res, 'Benutzername und Passwort (min. 8 Zeichen) erforderlich.');
|
||||
}
|
||||
const id = randomUUID();
|
||||
const ph = await hashPassword(password);
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, username, password_hash, role, source, active, updated_at)
|
||||
VALUES (?, ?, ?, 'admin', 'local', 1, datetime('now'))`,
|
||||
).run(id, un, ph);
|
||||
req.session.userId = id;
|
||||
req.session.role = 'admin';
|
||||
req.session.username = un;
|
||||
res.status(201).json({
|
||||
user: { id, username: un, role: 'admin' },
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
const { username, password } = req.body || {};
|
||||
const un = String(username || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!un || !password) {
|
||||
return badRequest(res, 'Benutzername und Passwort erforderlich.');
|
||||
}
|
||||
const u = db.prepare('SELECT * FROM users WHERE username = ?').get(un);
|
||||
if (!u || !u.active) {
|
||||
return res.status(401).json({ message: 'Ungültige Zugangsdaten.' });
|
||||
}
|
||||
if (!u.password_hash) {
|
||||
return res.status(401).json({
|
||||
message: 'Kein lokales Passwort (LDAP). Anmeldung folgt mit Verzeichnis-Sync.',
|
||||
});
|
||||
}
|
||||
const ok = await verifyPassword(password, u.password_hash);
|
||||
if (!ok) return res.status(401).json({ message: 'Ungültige Zugangsdaten.' });
|
||||
req.session.userId = u.id;
|
||||
req.session.role = u.role;
|
||||
req.session.username = u.username;
|
||||
res.json({
|
||||
user: { id: u.id, username: u.username, role: u.role },
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) return res.status(500).json({ message: 'Abmelden fehlgeschlagen.' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -36,6 +36,18 @@ export function computeRemoteDurationSeconds(startIso, endIso) {
|
||||
* jüngster Session je Gerät; Anzeigename Gerät = devicename oder sonst deviceid.
|
||||
*/
|
||||
/** TeamViewer liefert Feldnamen je nach Version leicht unterschiedlich (userid / user_id / …). */
|
||||
/** TeamViewer Connection Report: optionales Freitextfeld */
|
||||
function pickNotes(rec) {
|
||||
const raw =
|
||||
rec.notes ??
|
||||
rec.Notes ??
|
||||
rec.connection_notes ??
|
||||
rec.session_notes ??
|
||||
'';
|
||||
const s = String(raw ?? '').trim();
|
||||
return s || null;
|
||||
}
|
||||
|
||||
function pickUserIdentity(rec) {
|
||||
const uid = String(
|
||||
rec.userid ?? rec.user_id ?? rec.userId ?? rec.UserID ?? '',
|
||||
@@ -112,6 +124,7 @@ function buildSessionsByUser(allRecords) {
|
||||
startDate: startDate || null,
|
||||
endDate: endDate || null,
|
||||
durationSeconds: computeRemoteDurationSeconds(startDate, endDate),
|
||||
notes: pickNotes(rec),
|
||||
});
|
||||
}
|
||||
devices.sort((a, b) =>
|
||||
|
||||
Reference in New Issue
Block a user