This commit is contained in:
2026-03-23 02:09:14 +01:00
parent 705329d3c2
commit d8d46ed8e9
61 changed files with 6054 additions and 3116 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
.git
*.log
.DS_Store
docker-data
.env
data

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
node_modules/
data/
docker-data/
.env
*.log
.DS_Store

Binary file not shown.

18
Dockerfile Normal file
View 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"]

View File

@@ -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
View 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
View File

@@ -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",

View File

@@ -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
View 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>

View File

@@ -0,0 +1 @@
/* Login / Bootstrap bei Bedarf seiten-spezifische Styles */

View File

@@ -0,0 +1 @@
/* Maschinen- und Ticket-Detail bei Bedarf seiten-spezifische Styles */

View 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);
}

View File

@@ -0,0 +1 @@
/* Optionen bei Bedarf seiten-spezifische Styles */

View File

@@ -0,0 +1 @@
/* Start bei Bedarf seiten-spezifische Styles */

View 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;
}

View File

@@ -0,0 +1 @@
/* Benutzer bei Bedarf seiten-spezifische Styles */

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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' });

File diff suppressed because it is too large Load Diff

View 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();
}
}

View 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;
}

View 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
View 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';
};
}
}

View 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>`;
}

View 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
View File

@@ -0,0 +1,60 @@
export function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** 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
View 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
View 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();

View 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
View 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
View 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
View 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)}` : ''} &nbsp;·&nbsp; <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();

View 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
View 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
View 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
View 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
View 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
View 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
View 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 &lt;token&gt;</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
View 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
View 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
View 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
View 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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
View 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
View 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
View 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`;

View 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;
}
}
}

View 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
View 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();
}

View 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;
}

View 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
View 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;
}
});
}

View 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;
}

View 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));
});
}

View 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), 15 = 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 (15 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 (15 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
View 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;

View File

@@ -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) =>