diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8e8433e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.git +*.log +.DS_Store +docker-data +.env +data diff --git a/.gitignore b/.gitignore index fb47ca2..f38cb21 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ data/ +docker-data/ .env *.log .DS_Store diff --git a/Anlagenliste ITT.xlsx b/Anlagenliste ITT.xlsx deleted file mode 100644 index 8eb00d8..0000000 Binary files a/Anlagenliste ITT.xlsx and /dev/null differ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d71bca8 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/database/init.sql b/database/init.sql index f120a06..e9e4af6 100644 --- a/database/init.sql +++ b/database/init.sql @@ -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, diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e155e96 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/package-lock.json b/package-lock.json index 80b1752..f7e4b73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b079063..494f01e 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/public/bootstrap.html b/public/bootstrap.html new file mode 100644 index 0000000..d8f94be --- /dev/null +++ b/public/bootstrap.html @@ -0,0 +1,33 @@ + + + + + + Erster Administrator — SDS CRM + + + + +
+

SDS CRM

+ +
+
+

Lade …

+ +
+ + + diff --git a/public/css/pages/auth.css b/public/css/pages/auth.css new file mode 100644 index 0000000..1d6fbb4 --- /dev/null +++ b/public/css/pages/auth.css @@ -0,0 +1 @@ +/* Login / Bootstrap – bei Bedarf seiten-spezifische Styles */ diff --git a/public/css/pages/detail.css b/public/css/pages/detail.css new file mode 100644 index 0000000..e082daa --- /dev/null +++ b/public/css/pages/detail.css @@ -0,0 +1 @@ +/* Maschinen- und Ticket-Detail – bei Bedarf seiten-spezifische Styles */ diff --git a/public/css/pages/machines.css b/public/css/pages/machines.css new file mode 100644 index 0000000..e79fe27 --- /dev/null +++ b/public/css/pages/machines.css @@ -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); +} diff --git a/public/css/pages/options.css b/public/css/pages/options.css new file mode 100644 index 0000000..81a28cd --- /dev/null +++ b/public/css/pages/options.css @@ -0,0 +1 @@ +/* Optionen – bei Bedarf seiten-spezifische Styles */ diff --git a/public/css/pages/start.css b/public/css/pages/start.css new file mode 100644 index 0000000..4dec9a8 --- /dev/null +++ b/public/css/pages/start.css @@ -0,0 +1 @@ +/* Start – bei Bedarf seiten-spezifische Styles */ diff --git a/public/css/pages/tickets.css b/public/css/pages/tickets.css new file mode 100644 index 0000000..8d83208 --- /dev/null +++ b/public/css/pages/tickets.css @@ -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; +} diff --git a/public/css/pages/users.css b/public/css/pages/users.css new file mode 100644 index 0000000..1c3f960 --- /dev/null +++ b/public/css/pages/users.css @@ -0,0 +1 @@ +/* Benutzer – bei Bedarf seiten-spezifische Styles */ diff --git a/public/css/style.css b/public/css/style.css index 4f861df..d67f41b 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1,866 +1,1038 @@ -:root { - --bg: #0d1117; - --bg-card: #161b22; - --bg-raised: #1c2330; - --bg-hover: #212836; - --border: #30363d; - --border-hi: #444c56; - --text: #e6edf3; - --text-sub: #8b949e; - --text-muted:#6e7681; - --accent: #2f81f7; - --accent-hi: #58a6ff; - --green: #238636; - --green-fg: #3fb950; - --amber: #9e6a03; - --amber-fg: #d29922; - --red: #b91c1c; - --red-fg: #f85149; - - font-family: 'Segoe UI', system-ui, sans-serif; - font-size: 14px; - line-height: 1.55; - color: var(--text); - background: var(--bg); -} - -* { box-sizing: border-box; } - -body { margin: 0; } - -a { - color: var(--accent-hi); - text-decoration: none; -} -a:hover { text-decoration: underline; } - -/* ── Header ──────────────────────────────────────── */ -.header { - background: #010409; - border-bottom: 1px solid var(--border); - padding: 0 1.5rem; - height: 52px; - display: flex; - align-items: center; - gap: 2rem; - position: sticky; - top: 0; - z-index: 100; -} - -.header h1 { - margin: 0; - font-size: 1rem; - font-weight: 600; - color: var(--text); - letter-spacing: 0.02em; - white-space: nowrap; -} - -.header nav { display: flex; gap: 0.25rem; } - -.header nav a { - color: var(--text-sub); - padding: 0.35rem 0.75rem; - border-radius: 6px; - font-size: 0.9rem; - transition: color 0.1s, background 0.1s; -} - -.header nav a:hover { - color: var(--text); - background: var(--bg-raised); - text-decoration: none; -} - -.header h1 a { - color: inherit; -} - -#main-nav { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.75rem 1rem; -} - -.nav-user { - font-size: 0.85rem; -} - -.btn-nav-logout { - padding: 0.3rem 0.65rem; - font-size: 0.85rem; -} - -.auth-panel { - max-width: 28rem; -} - -.row-inline { - flex-direction: row; - align-items: center; - gap: 0.5rem; -} - -.users-table .users-actions { - white-space: nowrap; -} - -.users-table .users-actions button { - margin-right: 0.35rem; - margin-bottom: 0.25rem; -} - -.options-page .options-section-title { - margin: 0 0 0.5rem; - font-size: 1.05rem; - font-weight: 600; - color: var(--text); - text-transform: none; - letter-spacing: normal; -} - -.options-page .options-section .muted code { - font-size: 0.85em; -} - -/* LDAP-Synchronisation (Referenz-Layout) */ -.ldap-section { - padding: 0; - overflow: hidden; -} - -.ldap-section-toggle { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 0.85rem 1.25rem; - margin: 0; - border: none; - border-bottom: 1px solid var(--border); - background: var(--bg-raised); - color: var(--text); - font: inherit; - font-size: 1rem; - font-weight: 600; - cursor: pointer; - text-align: left; -} - -.ldap-section-toggle:hover { - background: var(--bg-hover); -} - -.ldap-section-heading { - flex: 1; -} - -.ldap-chevron { - font-size: 0.75rem; - color: var(--text-muted); - flex-shrink: 0; -} - -.ldap-section-body { - padding: 1rem 1.25rem 1.25rem; -} - -.ldap-section-body[hidden] { - display: none !important; -} - -.ldap-subtitle { - margin: 0 0 1rem; - font-size: 0.95rem; - font-weight: 600; - color: var(--text-sub); -} - -.ldap-sync-check { - margin-bottom: 1rem; -} - -.form-grid-2 { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0.75rem 1rem; - margin-bottom: 0.75rem; -} - -@media (max-width: 720px) { - .form-grid-2 { - grid-template-columns: 1fr; - } -} - -.form-grid-ldap-attr { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - gap: 0.75rem 1rem; - margin-bottom: 0.75rem; -} - -@media (max-width: 900px) { - .form-grid-ldap-attr { - grid-template-columns: 1fr; - } -} - -.options-page label.full-width { - display: flex; - flex-direction: column; - gap: 0.35rem; - margin-bottom: 0.75rem; -} - -.ldap-filter-ta { - font-family: ui-monospace, monospace; - font-size: 0.82rem; - line-height: 1.45; - min-height: 5rem; -} - -.ldap-hint { - margin: -0.35rem 0 0.75rem; - font-size: 0.82rem; -} - -.options-actions { - display: flex; - justify-content: flex-start; -} - -.btn-config-save { - padding: 0.5rem 1.25rem; - font-weight: 600; -} - -/* LDAP Sync-Panel (Optionen) */ -.sync-panel .sync-actions { - margin-bottom: 0.75rem; -} - -.btn-ldap-sync-now { - padding: 0.5rem 1.1rem; - font-weight: 600; -} - -.sync-last-line { - margin: 0 0 1rem; - font-size: 0.92rem; -} - -.sync-log-title { - margin: 0 0 0.65rem; - font-size: 0.95rem; - font-weight: 600; - color: var(--text-sub); - text-transform: none; - letter-spacing: normal; -} - -.sync-log-table-wrap { - overflow-x: auto; -} - -table.sync-log-table { - font-size: 0.88rem; -} - -table.sync-log-table th, -table.sync-log-table td { - padding: 0.45rem 0.65rem; - border-bottom: 1px solid var(--border); - vertical-align: top; -} - -table.sync-log-table th { - font-weight: 600; - color: var(--text-sub); - text-align: left; -} - -table.sync-log-table td.num { - text-align: right; - font-variant-numeric: tabular-nums; -} - -.sync-status-badge { - display: inline-block; - padding: 0.15rem 0.55rem; - border-radius: 999px; - font-size: 0.82rem; - font-weight: 600; -} - -.sync-status-badge.sync-status-ok { - background: rgba(46, 160, 67, 0.25); - color: #3fb950; - border: 1px solid rgba(63, 185, 80, 0.45); -} - -.sync-status-badge.sync-status-err { - background: rgba(248, 81, 73, 0.15); - color: var(--red, #f85149); - border: 1px solid rgba(248, 81, 73, 0.35); -} - -.tv-device-row { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: 0.75rem; -} - -.tv-device-row label { - flex: 1; - min-width: 12rem; -} - -.tv-conn-hint { - margin: 0.25rem 0 0; - font-size: 0.85rem; -} - -/* ── Layout ──────────────────────────────────────── */ -.main { - padding: 1.5rem 2rem; - max-width: 100%; -} - -/* ── Card ────────────────────────────────────────── */ -.card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 8px; - padding: 1rem 1.25rem; - margin-bottom: 1rem; -} - -/* ── Stack / Row ─────────────────────────────────── */ -.stack { - display: flex; - flex-direction: column; - gap: 0.85rem; -} - -.row { - display: flex; - gap: 0.75rem; - flex-wrap: wrap; - align-items: flex-end; -} - -/* ── Typography ─────────────────────────────────── */ -h2 { margin: 0 0 0.5rem; font-size: 1.25rem; } -h3, h4 { margin: 0 0 0.5rem; font-size: 0.95rem; color: var(--text-sub); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; } - -.muted { color: var(--text-muted); font-size: 0.9rem; } -.error { color: var(--red-fg); font-size: 0.9rem; } - -/* ── Forms ───────────────────────────────────────── */ -label { - display: flex; - flex-direction: column; - gap: 0.3rem; - font-size: 0.85rem; - font-weight: 500; - color: var(--text-sub); -} - -input, select, textarea, button { font: inherit; } - -input, select, textarea { - padding: 0.45rem 0.65rem; - background: var(--bg); - border: 1px solid var(--border); - border-radius: 6px; - color: var(--text); - min-width: 180px; - transition: border-color 0.1s; -} - -input:focus, select:focus, textarea:focus { - outline: none; - border-color: var(--accent); -} - -textarea { min-height: 72px; resize: vertical; } - -button { - padding: 0.45rem 0.9rem; - border-radius: 6px; - border: 1px solid var(--accent); - background: var(--accent); - color: #fff; - cursor: pointer; - font-weight: 500; - transition: opacity 0.12s; -} -button:hover { opacity: 0.85; } -button.secondary { - background: transparent; - color: var(--accent-hi); - border-color: var(--border-hi); -} -button.secondary:hover { background: var(--bg-hover); opacity: 1; } -button.danger { border-color: var(--red); background: var(--red); color: #fff; } -button:disabled { opacity: 0.4; cursor: not-allowed; } - -/* ── Table (global) ──────────────────────────────── */ -table { - width: 100%; - border-collapse: collapse; - font-size: 0.92rem; -} - -th, td { - text-align: left; - padding: 0.55rem 0.6rem; - border-bottom: 1px solid var(--border); -} - -th { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-sub); - font-weight: 600; -} - -/* ── Badge ───────────────────────────────────────── */ -.badge { - display: inline-block; - padding: 0.15rem 0.55rem; - border-radius: 999px; - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.03em; - background: var(--bg-raised); - color: var(--text-sub); - border: 1px solid var(--border-hi); -} - -/* ── Timeline ────────────────────────────────────── */ -.timeline { - border-left: 2px solid var(--border); - margin-left: 0.35rem; - padding-left: 1rem; -} -.timeline-item { margin-bottom: 1rem; } -.timeline-item time { font-size: 0.78rem; color: var(--text-muted); } - -/* ── Table-Wrap ──────────────────────────────────── */ -.table-wrap { - overflow-x: auto; - -webkit-overflow-scrolling: touch; -} - -/* ── Extras / Anlagen ────────────────────────────── */ -.extras-table { font-size: 0.88rem; } -.extras-table th { - vertical-align: top; - font-weight: 600; - text-transform: none; - letter-spacing: normal; - color: var(--text-sub); - border-right: 1px solid var(--border); -} -/* Nur Key-Value-Extras: feste Schlüsselspalte (nicht Anlagenliste mit 3 Spalten) */ -.extras-table:not(.anlagen-voll) th { - width: 11rem; -} - -.extras-cell-input { - width: 100%; - min-width: 0; - box-sizing: border-box; - font: inherit; - font-size: 0.88rem; - padding: 0.35rem 0.45rem; - border-radius: 4px; - border: 1px solid var(--border); - background: var(--bg); - color: var(--text); -} -.extras-table th .extras-cell-input { - font-weight: 500; - text-transform: none; - letter-spacing: normal; -} - -.machine-detail-card-title { - margin-top: 0; - font-size: 1rem; - font-weight: 600; -} - -.machine-detail-actions { - flex-wrap: wrap; - align-items: center; - gap: 0.75rem; -} -/* Anlagenliste: Gruppe + Beschreibung schmal aber lesbar, „Wert“ der Rest. - Kein width:100% auf der letzten Zelle — das drückt Spalte 2 auf Mindestbreite. */ -.anlagen-voll { - width: 100%; - table-layout: fixed; -} -/* Spaltenbreiten über colgroup: 1+2 kompakt, 3 bekommt den Rest (kein width:100% auf Zellen) */ -.anlagen-voll col.anlagen-col-gruppe { - width: 10rem; -} -.anlagen-voll col.anlagen-col-beschr { - width: 22%; - min-width: 11rem; -} -.anlagen-voll col.anlagen-col-wert { - width: auto; -} -.anlagen-voll:not(.gruppiert) thead th { - text-transform: none; - letter-spacing: normal; - font-size: 0.75rem; - vertical-align: bottom; -} -.anlagen-voll:not(.gruppiert) thead th:nth-child(1), -.anlagen-voll:not(.gruppiert) tbody td:nth-child(1) { - vertical-align: top; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-size: 0.85rem; -} -.anlagen-voll:not(.gruppiert) thead th:nth-child(2), -.anlagen-voll:not(.gruppiert) tbody th:nth-child(2) { - vertical-align: top; - max-width: 28rem; - white-space: normal; - word-break: break-word; - hyphens: auto; -} -.anlagen-voll:not(.gruppiert) thead th:nth-child(3), -.anlagen-voll:not(.gruppiert) tbody td:nth-child(3) { - vertical-align: top; - word-break: break-word; -} - -/* Gruppierte Anlagenliste: Box pro Gruppe, Kopfzeile = Gruppenname */ -.anlagen-voll.gruppiert col.anlagen-col-beschr { - width: 22%; - min-width: 11rem; -} -.anlagen-voll.gruppiert col.anlagen-col-wert { - width: auto; -} -.anlagen-voll.gruppiert thead th { - text-transform: none; - letter-spacing: normal; - font-size: 0.75rem; - vertical-align: bottom; -} -.anlagen-voll.gruppiert thead th:first-child, -.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) th { - vertical-align: top; - max-width: 28rem; - white-space: normal; - word-break: break-word; - hyphens: auto; -} -.anlagen-voll.gruppiert thead th:last-child, -.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) td { - vertical-align: top; - word-break: break-word; -} -.anlagen-voll.gruppiert tbody.anlagen-gruppe-spacer td { - height: 0.85rem; - padding: 0 !important; - border: none !important; - background: transparent !important; -} -.anlagen-voll.gruppiert tr.anlagen-gruppe-kopf td { - font-weight: 600; - font-size: 0.8rem; - text-transform: none; - letter-spacing: 0.02em; - color: var(--text); - background: var(--bg-raised); - border: 1px solid var(--border-hi); - border-bottom: 1px solid var(--border); - padding: 0.5rem 0.65rem; -} -.anlagen-voll.gruppiert tbody.anlagen-gruppe tr.anlagen-gruppe-kopf td:first-child { - border-radius: 8px 8px 0 0; -} -.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) th, -.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) td { - border-left: 1px solid var(--border-hi); - border-right: 1px solid var(--border-hi); -} -.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) th { - border-right: 1px solid var(--border); -} -.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf):last-child th:first-child { - border-bottom: 1px solid var(--border-hi); - border-bottom-left-radius: 8px; -} -.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf):last-child td:last-child { - border-bottom: 1px solid var(--border-hi); - border-bottom-right-radius: 8px; -} - -.main:has(.machines-overview) { max-width: 100%; } - -code { - font-size: 0.85em; - background: var(--bg-raised); - color: var(--text-sub); - padding: 0.1em 0.35em; - border-radius: 4px; -} - -/* ═══════════════════════════════════════════════════ - Startseite — Offene Tickets -════════════════════════════════════════════════════ */ -.home-open-tickets { gap: 1rem; } - -.home-kpi-bar { - display: flex; - align-items: center; - gap: 1.25rem; - flex-wrap: wrap; -} - -.home-kpi-pills { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; -} - -.kpi-pill .badge { - font-size: 0.8rem; - padding: 0.2rem 0.65rem; -} - -.home-open-tickets h2 { - font-size: 1.1rem; - color: var(--text); - font-weight: 600; - letter-spacing: 0; - text-transform: none; - margin: 0; -} - -.home-ticket-list { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.home-ticket-card { - padding: 0; - overflow: hidden; -} - -.home-ticket-top { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - justify-content: space-between; - gap: 0.75rem 1rem; - padding: 0.85rem 1.1rem; - border-bottom: 1px solid var(--border); - background: var(--bg-raised); -} - -.home-ticket-top-inner { - flex: 1; - min-width: 12rem; -} - -.home-ticket-titleline { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem 0.75rem; - font-size: 0.95rem; - margin-bottom: 0.35rem; -} - -.home-ticket-titleline > a { - font-weight: 600; -} - -.home-ticket-meta-row { - font-size: 0.8rem; - color: var(--text-muted); - display: flex; - flex-wrap: wrap; - gap: 0.35rem 1rem; -} - -.home-ticket-open { - display: inline-block; - padding: 0.35rem 0.75rem; - border: 1px solid var(--accent); - border-radius: 6px; - font-size: 0.82rem; - font-weight: 500; - color: var(--accent-hi); - transition: background 0.1s; - flex-shrink: 0; - text-decoration: none; -} - -.home-ticket-open:hover { - background: rgba(47, 129, 247, 0.12); - text-decoration: none; -} - -.home-ticket-context { - padding: 0.85rem 1.1rem; - border-bottom: 1px solid var(--border); -} - -.home-ticket-context-h { - margin: 0 0 0.4rem; - font-size: 0.82rem; -} - -.home-ticket-desc { - white-space: pre-wrap; - word-break: break-word; - color: var(--text-sub); - font-size: 0.88rem; - line-height: 1.55; - margin: 0; -} - -.home-ticket-machine { - margin: 0.65rem 0 0; - font-size: 0.85rem; - color: var(--text-sub); -} - -.home-ticket-events { - padding: 0.75rem 1.1rem 1rem; - display: flex; - flex-direction: column; - gap: 0.65rem; -} - -.home-ticket-no-events { - margin: 0; - font-size: 0.88rem; -} - -.home-event-box { - border: 1px solid var(--border); - border-radius: 6px; - padding: 0.65rem 0.85rem; - background: var(--bg); - font-size: 0.88rem; -} - -.home-event-box-header { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem 0.75rem; - margin-bottom: 0.45rem; -} - -.home-event-box-time { - font-size: 0.78rem; - color: var(--text-muted); -} - -.home-event-box-body :last-child { - margin-bottom: 0; -} - -/* Status badge variants */ -.badge-open { background: rgba(35, 134, 54, 0.15); color: var(--green-fg); border-color: rgba(63, 185, 80, 0.3); } -.badge-waiting { background: rgba(158, 106, 3, 0.15); color: var(--amber-fg); border-color: rgba(210, 153, 34, 0.3); } -.badge-done { background: rgba(110, 118, 129, 0.12); color: var(--text-muted); border-color: var(--border); } - -/* Priority badge variants */ -.badge-high { background: rgba(185, 28, 28, 0.18); color: var(--red-fg); border-color: rgba(248, 81, 73, 0.35); } -.badge-medium { background: rgba(158, 106, 3, 0.15); color: var(--amber-fg); border-color: rgba(210, 153, 34, 0.3); } -.badge-low { background: rgba(35, 134, 54, 0.1); color: var(--green-fg); border-color: rgba(63, 185, 80, 0.2); } - -/* Ticket-Detail: Ereignisse als Tabelle (neueste zuerst) */ -.events-table { - font-size: 0.9rem; -} - -.events-table thead th { - background: var(--bg-raised); - white-space: nowrap; -} - -.events-table-time { - color: var(--text-sub); - font-variant-numeric: tabular-nums; - white-space: nowrap; - width: 1%; - vertical-align: top; -} - -.events-table-desc { - color: var(--text-sub); - vertical-align: top; - line-height: 1.5; -} - -.event-inhalt-block { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.event-inhalt-label { - margin: 0; - font-size: 0.72rem; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-muted); - font-weight: 600; -} - -.event-inhalt-text { - white-space: pre-wrap; - word-break: break-word; - margin: 0; -} - -.event-inhalt-meta { - margin: 0; - font-size: 0.88rem; -} - -.event-artnr { - font-size: 0.95em; -} - -.event-tv-placeholder { - font-size: 0.85em; -} - -.ev-field-group[hidden] { - display: none !important; -} - -.event-type-badge { - white-space: nowrap; -} - -.event-type-note { background: rgba(88, 166, 255, 0.12); color: var(--accent-hi); border-color: rgba(88, 166, 255, 0.35); } -.event-type-call { background: rgba(163, 113, 247, 0.14); color: #d2a8ff; border-color: rgba(163, 113, 247, 0.35); } -.event-type-remote { background: rgba(35, 134, 54, 0.15); color: var(--green-fg); border-color: rgba(63, 185, 80, 0.3); } -.event-type-part { background: rgba(210, 153, 34, 0.14); color: var(--amber-fg); border-color: rgba(210, 153, 34, 0.35); } -.event-type-system { background: rgba(110, 118, 129, 0.18); color: var(--text-muted); border-color: var(--border-hi); } +:root { + --bg: #0d1117; + --bg-card: #161b22; + --bg-raised: #1c2330; + --bg-hover: #212836; + --border: #30363d; + --border-hi: #444c56; + --text: #e6edf3; + --text-sub: #8b949e; + --text-muted:#6e7681; + --accent: #2f81f7; + --accent-hi: #58a6ff; + --green: #238636; + --green-fg: #3fb950; + --amber: #9e6a03; + --amber-fg: #d29922; + --red: #b91c1c; + --red-fg: #f85149; + + font-family: 'Segoe UI', system-ui, sans-serif; + font-size: 14px; + line-height: 1.55; + color: var(--text); + background: var(--bg); +} + +* { box-sizing: border-box; } + +body { margin: 0; } + +a { + color: var(--accent-hi); + text-decoration: none; +} +a:hover { text-decoration: underline; } + +/* ── Header ──────────────────────────────────────── */ +.header { + background: #010409; + border-bottom: 1px solid var(--border); + padding: 0 1.5rem; + height: 52px; + display: flex; + align-items: center; + gap: 2rem; + position: sticky; + top: 0; + z-index: 100; +} + +.header h1 { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--text); + letter-spacing: 0.02em; + white-space: nowrap; +} + +.header nav { display: flex; gap: 0.25rem; } + +.header nav a { + color: var(--text-sub); + padding: 0.35rem 0.75rem; + border-radius: 6px; + font-size: 0.9rem; + transition: color 0.1s, background 0.1s; +} + +.header nav a:hover { + color: var(--text); + background: var(--bg-raised); + text-decoration: none; +} + +.header nav a.nav-active { + color: var(--text); + background: var(--bg-raised); + font-weight: 600; +} + +.header h1 a { + color: inherit; +} + +#main-nav { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem 1rem; +} + +.nav-user { + font-size: 0.85rem; +} + +.btn-nav-logout { + padding: 0.3rem 0.65rem; + font-size: 0.85rem; +} + +.auth-panel { + max-width: 28rem; +} + +.row-inline { + flex-direction: row; + align-items: center; + gap: 0.5rem; +} + +.users-table .users-actions { + white-space: nowrap; +} + +.users-table .users-actions button { + margin-right: 0.35rem; + margin-bottom: 0.25rem; +} + +.options-page .options-section-title { + margin: 0 0 0.5rem; + font-size: 1.05rem; + font-weight: 600; + color: var(--text); + text-transform: none; + letter-spacing: normal; +} + +.options-page .options-section .muted code { + font-size: 0.85em; +} + +/* LDAP-Synchronisation (Referenz-Layout) */ +.ldap-section { + padding: 0; + overflow: hidden; +} + +.ldap-section-toggle { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 1.25rem; + margin: 0; + border: none; + border-bottom: 1px solid var(--border); + background: var(--bg-raised); + color: var(--text); + font: inherit; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + text-align: left; +} + +.ldap-section-toggle:hover { + background: var(--bg-hover); +} + +.ldap-section-heading { + flex: 1; +} + +.ldap-chevron { + font-size: 0.75rem; + color: var(--text-muted); + flex-shrink: 0; +} + +.ldap-section-body { + padding: 1rem 1.25rem 1.25rem; +} + +.ldap-section-body[hidden] { + display: none !important; +} + +.ldap-subtitle { + margin: 0 0 1rem; + font-size: 0.95rem; + font-weight: 600; + color: var(--text-sub); +} + +.ldap-sync-check { + margin-bottom: 1rem; +} + +.form-grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem 1rem; + margin-bottom: 0.75rem; +} + +@media (max-width: 720px) { + .form-grid-2 { + grid-template-columns: 1fr; + } +} + +.form-grid-ldap-attr { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 0.75rem 1rem; + margin-bottom: 0.75rem; +} + +@media (max-width: 900px) { + .form-grid-ldap-attr { + grid-template-columns: 1fr; + } +} + +.options-page label.full-width { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-bottom: 0.75rem; +} + +.ldap-filter-ta { + font-family: ui-monospace, monospace; + font-size: 0.82rem; + line-height: 1.45; + min-height: 5rem; +} + +.ldap-hint { + margin: -0.35rem 0 0.75rem; + font-size: 0.82rem; +} + +.options-actions { + display: flex; + justify-content: flex-start; +} + +.btn-config-save { + padding: 0.5rem 1.25rem; + font-weight: 600; +} + +/* LDAP Sync-Panel (Optionen) */ +.sync-panel .sync-actions { + margin-bottom: 0.75rem; +} + +.btn-ldap-sync-now { + padding: 0.5rem 1.1rem; + font-weight: 600; +} + +.sync-last-line { + margin: 0 0 1rem; + font-size: 0.92rem; +} + +.sync-log-title { + margin: 0 0 0.65rem; + font-size: 0.95rem; + font-weight: 600; + color: var(--text-sub); + text-transform: none; + letter-spacing: normal; +} + +.sync-log-table-wrap { + overflow-x: auto; +} + +table.sync-log-table { + font-size: 0.88rem; +} + +table.sync-log-table th, +table.sync-log-table td { + padding: 0.45rem 0.65rem; + border-bottom: 1px solid var(--border); + vertical-align: top; +} + +table.sync-log-table th { + font-weight: 600; + color: var(--text-sub); + text-align: left; +} + +table.sync-log-table td.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.sync-status-badge { + display: inline-block; + padding: 0.15rem 0.55rem; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 600; +} + +.sync-status-badge.sync-status-ok { + background: rgba(46, 160, 67, 0.25); + color: #3fb950; + border: 1px solid rgba(63, 185, 80, 0.45); +} + +.sync-status-badge.sync-status-err { + background: rgba(248, 81, 73, 0.15); + color: var(--red, #f85149); + border: 1px solid rgba(248, 81, 73, 0.35); +} + +.tv-device-row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.75rem; +} + +.tv-device-row label { + flex: 1; + min-width: 12rem; +} + +.tv-conn-hint { + margin: 0.25rem 0 0; + font-size: 0.85rem; +} + +/* ── Layout ──────────────────────────────────────── */ +.main { + padding: 1.5rem 2rem; + max-width: 100%; +} + +/* ── Card ────────────────────────────────────────── */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem 1.25rem; + margin-bottom: 1rem; +} + +/* ── Stack / Row ─────────────────────────────────── */ +.stack { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +/* [hidden] muss trotz .stack gelten (sonst bleibt z. B. #form-m sichtbar) */ +.stack[hidden] { + display: none !important; +} + +.row { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + align-items: flex-end; +} + +/* ── Typography ─────────────────────────────────── */ +h2 { margin: 0 0 0.5rem; font-size: 1.25rem; } +h3, h4 { margin: 0 0 0.5rem; font-size: 0.95rem; color: var(--text-sub); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; } + +.muted { color: var(--text-muted); font-size: 0.9rem; } +.error { color: var(--red-fg); font-size: 0.9rem; } + +/* ── Forms ───────────────────────────────────────── */ +label { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.85rem; + font-weight: 500; + color: var(--text-sub); +} + +input, select, textarea, button { font: inherit; } + +input, select, textarea { + padding: 0.45rem 0.65rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + min-width: 180px; + transition: border-color 0.1s; +} + +input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--accent); +} + +textarea { min-height: 72px; resize: vertical; } + +button { + padding: 0.45rem 0.9rem; + border-radius: 6px; + border: 1px solid var(--accent); + background: var(--accent); + color: #fff; + cursor: pointer; + font-weight: 500; + transition: opacity 0.12s; +} +button:hover { opacity: 0.85; } +button.secondary { + background: transparent; + color: var(--accent-hi); + border-color: var(--border-hi); +} +button.secondary:hover { background: var(--bg-hover); opacity: 1; } +button.danger { border-color: var(--red); background: var(--red); color: #fff; } +button:disabled { opacity: 0.4; cursor: not-allowed; } + +/* ── Table (global) ──────────────────────────────── */ +table { + width: 100%; + border-collapse: collapse; + font-size: 0.92rem; +} + +th, td { + text-align: left; + padding: 0.55rem 0.6rem; + border-bottom: 1px solid var(--border); +} + +th { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-sub); + font-weight: 600; +} + +/* ── Badge ───────────────────────────────────────── */ +.badge { + display: inline-block; + padding: 0.15rem 0.55rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.03em; + background: var(--bg-raised); + color: var(--text-sub); + border: 1px solid var(--border-hi); +} + +/* ── Timeline ────────────────────────────────────── */ +.timeline { + border-left: 2px solid var(--border); + margin-left: 0.35rem; + padding-left: 1rem; +} +.timeline-item { margin-bottom: 1rem; } +.timeline-item time { font-size: 0.78rem; color: var(--text-muted); } + +/* ── Table-Wrap ──────────────────────────────────── */ +.table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +/* ── Extras / Anlagen ────────────────────────────── */ +.extras-table { font-size: 0.88rem; } +.extras-table th { + vertical-align: top; + font-weight: 600; + text-transform: none; + letter-spacing: normal; + color: var(--text-sub); + border-right: 1px solid var(--border); +} +/* Nur Key-Value-Extras: feste Schlüsselspalte (nicht Anlagenliste mit 3 Spalten) */ +.extras-table:not(.anlagen-voll) th { + width: 11rem; +} + +.extras-cell-input { + width: 100%; + min-width: 0; + box-sizing: border-box; + font: inherit; + font-size: 0.88rem; + padding: 0.35rem 0.45rem; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); +} +.extras-table th .extras-cell-input { + font-weight: 500; + text-transform: none; + letter-spacing: normal; +} + +.machine-detail-card-title { + margin-top: 0; + font-size: 1rem; + font-weight: 600; +} + +.machine-detail-actions { + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} + +.machine-detail-card-title + .machine-detail-actions { + margin-bottom: 0.75rem; +} +/* Anlagenliste: Gruppe + Beschreibung schmal aber lesbar, „Wert“ der Rest. + Kein width:100% auf der letzten Zelle — das drückt Spalte 2 auf Mindestbreite. */ +.anlagen-voll { + width: 100%; + table-layout: fixed; +} +/* Spaltenbreiten über colgroup: 1+2 kompakt, 3 bekommt den Rest (kein width:100% auf Zellen) */ +.anlagen-voll col.anlagen-col-gruppe { + width: 10rem; +} +.anlagen-voll col.anlagen-col-beschr { + width: 22%; + min-width: 11rem; +} +.anlagen-voll col.anlagen-col-wert { + width: auto; +} +.anlagen-voll:not(.gruppiert) thead th { + text-transform: none; + letter-spacing: normal; + font-size: 0.75rem; + vertical-align: bottom; +} +.anlagen-voll:not(.gruppiert) thead th:nth-child(1), +.anlagen-voll:not(.gruppiert) tbody td:nth-child(1) { + vertical-align: top; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.85rem; +} +.anlagen-voll:not(.gruppiert) thead th:nth-child(2), +.anlagen-voll:not(.gruppiert) tbody th:nth-child(2) { + vertical-align: top; + max-width: 28rem; + white-space: normal; + word-break: break-word; + hyphens: auto; +} +.anlagen-voll:not(.gruppiert) thead th:nth-child(3), +.anlagen-voll:not(.gruppiert) tbody td:nth-child(3) { + vertical-align: top; + word-break: break-word; +} + +/* Gruppierte Anlagenliste: Box pro Gruppe, Kopfzeile = Gruppenname */ +.anlagen-voll.gruppiert col.anlagen-col-beschr { + width: 22%; + min-width: 11rem; +} +.anlagen-voll.gruppiert col.anlagen-col-wert { + width: auto; +} +.anlagen-voll.gruppiert thead th { + text-transform: none; + letter-spacing: normal; + font-size: 0.75rem; + vertical-align: bottom; +} +.anlagen-voll.gruppiert thead th:first-child, +.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) th { + vertical-align: top; + max-width: 28rem; + white-space: normal; + word-break: break-word; + hyphens: auto; +} +.anlagen-voll.gruppiert thead th:last-child, +.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) td { + vertical-align: top; + word-break: break-word; +} +.anlagen-voll.gruppiert tbody.anlagen-gruppe-spacer td { + height: 0.85rem; + padding: 0 !important; + border: none !important; + background: transparent !important; +} +.anlagen-voll.gruppiert tr.anlagen-gruppe-kopf td { + font-weight: 600; + font-size: 0.8rem; + text-transform: none; + letter-spacing: 0.02em; + color: var(--text); + background: var(--bg-raised); + border: 1px solid var(--border-hi); + border-bottom: 1px solid var(--border); + padding: 0.5rem 0.65rem; +} +.anlagen-voll.gruppiert tbody.anlagen-gruppe tr.anlagen-gruppe-kopf td:first-child { + border-radius: 8px 8px 0 0; +} +.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) th, +.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) td { + border-left: 1px solid var(--border-hi); + border-right: 1px solid var(--border-hi); +} +.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) th { + border-right: 1px solid var(--border); +} +.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf):last-child th:first-child { + border-bottom: 1px solid var(--border-hi); + border-bottom-left-radius: 8px; +} +.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf):last-child td:last-child { + border-bottom: 1px solid var(--border-hi); + border-bottom-right-radius: 8px; +} + +.main:has(.machines-overview) { max-width: 100%; } + +code { + font-size: 0.85em; + background: var(--bg-raised); + color: var(--text-sub); + padding: 0.1em 0.35em; + border-radius: 4px; +} + +/* ═══════════════════════════════════════════════════ + Startseite — Offene Tickets +════════════════════════════════════════════════════ */ +.home-open-tickets { gap: 1rem; } + +.home-kpi-bar { + display: flex; + align-items: center; + gap: 1.25rem; + flex-wrap: wrap; +} + +.home-kpi-pills { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.kpi-pill .badge { + font-size: 0.8rem; + padding: 0.2rem 0.65rem; +} + +.home-open-tickets h2 { + font-size: 1.1rem; + color: var(--text); + font-weight: 600; + letter-spacing: 0; + text-transform: none; + margin: 0; +} + +.home-ticket-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.home-ticket-card { + padding: 0; + overflow: hidden; +} + +.home-ticket-head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem 1rem; + padding: 0.85rem 1.1rem; + border-bottom: 1px solid var(--border); + background: var(--bg-raised); +} + +.home-ticket-head.home-ticket-head-overdue { + background-color: rgba(180, 40, 40, 0.12); + box-shadow: inset 3px 0 0 0 #b42828; +} + +.home-ticket-collapse-btn { + flex-shrink: 0; + margin: 0; + padding: 0.2rem 0.35rem 0 0; + border: none; + background: transparent; + color: var(--text-muted); + font-size: 0.75rem; + line-height: 1.2; + cursor: pointer; + align-self: flex-start; +} + +.home-ticket-collapse-btn:hover { + color: var(--accent-hi); +} + +.home-ticket-head-main { + flex: 1; + min-width: 12rem; +} + +.home-ticket-collapsible[hidden] { + display: none !important; +} + +.home-ticket-titleline { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; + font-size: 0.95rem; + margin-bottom: 0.35rem; +} + +.home-ticket-titleline > a { + font-weight: 600; +} + +.home-ticket-meta-row { + font-size: 0.8rem; + color: var(--text-muted); + display: flex; + flex-wrap: wrap; + gap: 0.35rem 1rem; +} + +.home-ticket-open { + display: inline-block; + padding: 0.35rem 0.75rem; + border: 1px solid var(--accent); + border-radius: 6px; + font-size: 0.82rem; + font-weight: 500; + color: var(--accent-hi); + transition: background 0.1s; + flex-shrink: 0; + text-decoration: none; +} + +.home-ticket-open:hover { + background: rgba(47, 129, 247, 0.12); + text-decoration: none; +} + +.home-ticket-context { + padding: 0.85rem 1.1rem; + border-bottom: 1px solid var(--border); +} + +.home-ticket-context-h { + margin: 0 0 0.4rem; + font-size: 0.82rem; +} + +.home-ticket-desc { + white-space: pre-wrap; + word-break: break-word; + color: var(--text-sub); + font-size: 0.88rem; + line-height: 1.55; + margin: 0; +} + +.home-ticket-machine { + margin: 0.65rem 0 0; + font-size: 0.85rem; + color: var(--text-sub); +} + +.home-ticket-events { + padding: 0.75rem 1.1rem 1rem; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.home-ticket-no-events { + margin: 0; + font-size: 0.88rem; +} + +.home-event-box { + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.65rem 0.85rem; + background: var(--bg); + font-size: 0.88rem; +} + +.home-event-box-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; + margin-bottom: 0.45rem; +} + +.home-event-box-time { + font-size: 0.78rem; + color: var(--text-muted); +} + +.home-event-box-body :last-child { + margin-bottom: 0; +} + +/* Status badge variants */ +.badge-open { background: rgba(35, 134, 54, 0.15); color: var(--green-fg); border-color: rgba(63, 185, 80, 0.3); } +.badge-waiting { background: rgba(158, 106, 3, 0.15); color: var(--amber-fg); border-color: rgba(210, 153, 34, 0.3); } +.badge-done { background: rgba(110, 118, 129, 0.12); color: var(--text-muted); border-color: var(--border); } + +/* Priority badge variants */ +.badge-high { background: rgba(185, 28, 28, 0.18); color: var(--red-fg); border-color: rgba(248, 81, 73, 0.35); } +.badge-medium { background: rgba(158, 106, 3, 0.15); color: var(--amber-fg); border-color: rgba(210, 153, 34, 0.3); } +.badge-low { background: rgba(35, 134, 54, 0.1); color: var(--green-fg); border-color: rgba(63, 185, 80, 0.2); } + +/* Ticket-Detail: Ereignisse als Tabelle (neueste zuerst) */ +.events-table { + font-size: 0.9rem; +} + +.events-table thead th { + background: var(--bg-raised); + white-space: nowrap; +} + +.events-table-time { + color: var(--text-sub); + font-variant-numeric: tabular-nums; + white-space: nowrap; + width: 1%; + vertical-align: top; +} + +.events-table-desc { + color: var(--text-sub); + vertical-align: top; + line-height: 1.5; +} + +.event-inhalt-block { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.event-inhalt-label { + margin: 0; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + font-weight: 600; +} + +.event-inhalt-text { + white-space: pre-wrap; + word-break: break-word; + margin: 0; +} + +.event-inhalt-meta { + margin: 0; + font-size: 0.88rem; +} + +.event-artnr { + font-size: 0.95em; +} + +.event-tv-placeholder { + font-size: 0.85em; +} + +.ev-field-group[hidden] { + display: none !important; +} + +.event-type-badge { + white-space: nowrap; +} + +.event-type-note { background: rgba(88, 166, 255, 0.12); color: var(--accent-hi); border-color: rgba(88, 166, 255, 0.35); } +.event-type-call { background: rgba(163, 113, 247, 0.14); color: #d2a8ff; border-color: rgba(163, 113, 247, 0.35); } +.event-type-remote { background: rgba(35, 134, 54, 0.15); color: var(--green-fg); border-color: rgba(63, 185, 80, 0.3); } +.event-type-part { background: rgba(210, 153, 34, 0.14); color: var(--amber-fg); border-color: rgba(210, 153, 34, 0.35); } +.event-type-attachment { background: rgba(244, 143, 177, 0.12); color: #f48fb1; border-color: rgba(244, 143, 177, 0.35); } +.event-type-system { background: rgba(110, 118, 129, 0.18); color: var(--text-muted); border-color: var(--border-hi); } + +.event-attachment-list { + margin: 0.5rem 0 0; + padding-left: 1.25rem; +} +.event-attachment-list a { + font-weight: 500; +} + +/* Vorschau-Modal — kein display auf dem Basis-Selektor: sonst überschreibt flex das UA- display:none und der Dialog ist immer sichtbar */ +.attachment-preview-dialog { + border: 1px solid var(--border-hi, #444); + border-radius: 10px; + padding: 0; + max-width: min(960px, 96vw); + width: 96vw; + background: var(--bg, #0d1117); + color: var(--text, #e6edf3); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45); +} +.attachment-preview-dialog[open] { + display: flex; + flex-direction: column; + overflow: hidden; + height: min(90vh, 900px); + max-height: min(90vh, 900px); +} +.attachment-preview-dialog::backdrop { + background: rgba(0, 0, 0, 0.55); +} +.attachment-preview-inner { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} +.attachment-preview-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.65rem 1rem; + border-bottom: 1px solid var(--border-hi, #444); + flex-shrink: 0; +} +.attachment-preview-title { + margin: 0; + font-size: 1rem; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.attachment-preview-close { + flex-shrink: 0; + width: 2.25rem; + height: 2.25rem; + border: none; + border-radius: 6px; + background: var(--border-hi, #444); + color: inherit; + font-size: 1.35rem; + line-height: 1; + cursor: pointer; +} +.attachment-preview-close:hover { + filter: brightness(1.15); +} +.attachment-preview-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: center; +} +/* PDF: kein Scroll am äußeren Container — nur der Browser-PDF-Viewer im iframe scrollt */ +.attachment-preview-body--embed { + overflow: hidden; + padding: 0; +} +/* Bild, Text, Video, Fehler: ein gemeinsamer Scrollbereich */ +.attachment-preview-body--scroll { + overflow: auto; + padding: 0.75rem 1rem; + align-items: center; +} +.attachment-preview-img { + max-width: 100%; + max-height: min(75vh, 800px); + height: auto; + object-fit: contain; +} +.attachment-preview-iframe { + flex: 1; + min-height: 0; + width: 100%; + height: 100%; + border: none; + border-radius: 0; + background: #fff; +} +.attachment-preview-video { + max-width: 100%; + max-height: min(75vh, 800px); +} +.attachment-preview-audio { + width: 100%; + min-width: 280px; +} +.attachment-preview-text { + margin: 0; + align-self: stretch; + white-space: pre-wrap; + word-break: break-word; + font-size: 0.85rem; + line-height: 1.45; + max-height: min(75vh, 800px); + overflow: auto; + text-align: left; +} +.attachment-preview-footer { + padding: 0.65rem 1rem; + border-top: 1px solid var(--border-hi, #444); + flex-shrink: 0; +} +.attachment-preview-loading { + margin: 0; +} diff --git a/public/index.html b/public/index.html index f095543..bbc8753 100644 --- a/public/index.html +++ b/public/index.html @@ -4,14 +4,12 @@ SDS CRM - + + -
-

SDS CRM

- -
-
- +

Weiter zur Startseite

diff --git a/public/js/api.js b/public/js/api.js index 95f83e0..a87ccb4 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -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' }); diff --git a/public/js/app.js b/public/js/app.js deleted file mode 100644 index c437afc..0000000 --- a/public/js/app.js +++ /dev/null @@ -1,1502 +0,0 @@ -import { - apiDelete, - apiGet, - apiPost, - apiPut, - authFetchStatus, - isAuthRedirectError, -} from './api.js'; - -/** Zwischenspeicher für GET /integrations/teamviewer/connections (Benutzer → Geräte). */ -let tvSessionsCache = null; - -const ticketStatusLabel = { - OPEN: 'Offen', - WAITING: 'Warte auf Rückmeldung', - DONE: 'Erledigt', -}; - -const ticketPriorityLabel = { - LOW: 'Niedrig', - MEDIUM: 'Mittel', - HIGH: 'Hoch', -}; - -const eventTypeLabel = { - NOTE: 'Notiz', - CALL: 'Anruf', - REMOTE: 'Remote', - PART: 'Ersatzteil benötigt', - SYSTEM: 'System', -}; - -const eventTypeBadgeClass = { - NOTE: 'event-type-note', - CALL: 'event-type-call', - REMOTE: 'event-type-remote', - PART: 'event-type-part', - SYSTEM: 'event-type-system', -}; - -/** Anzeige Dauer aus gespeicherten Sekunden (TeamViewer start/end). */ -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.`; -} - -/** HTML für die Inhaltsspalte (nur server-/formularbekannte Typen) */ -function eventInhaltHtml(ev) { - const t = ev.type; - if (t === 'CALL') { - let h = `

Beschreibung

${esc(ev.description)}
`; - if (ev.callbackNumber) { - h += `

Rückrufnummer: ${esc(ev.callbackNumber)}

`; - } - return `${h}
`; - } - if (t === 'REMOTE') { - let h = `

Beschreibung

${esc(ev.description)}
`; - if (ev.teamviewerId) { - h += `

Gerät-ID (TeamViewer): ${esc(ev.teamviewerId)}

`; - } - if (ev.remoteDurationSeconds != null) { - h += `

Remote-Dauer: ${esc(formatRemoteDurationDe(ev.remoteDurationSeconds))}

`; - } - return `${h}
`; - } - if (t === 'PART') { - let h = `

Artikelnummer: ${esc(ev.articleNumber || '')}

`; - if (ev.description && String(ev.description).trim()) { - h += `

Bemerkung

${esc(ev.description)}
`; - } - return `${h}
`; - } - return `
${esc(ev.description)}
`; -} - -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 = - ''; - 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) => - ``, - ) - .join(''); -} - -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 = ''; - devSel.innerHTML = ''; - devSel.disabled = true; - if (hint) hint.textContent = ''; - try { - const data = await apiGet('/integrations/teamviewer/connections'); - tvSessionsCache = data; - const users = data.users || []; - userSel.innerHTML = - '' + - users - .map((u) => { - const label = - u.username && u.username !== '_unbekannt' - ? u.username - : u.userid - ? `Benutzer ${u.userid}` - : 'Unbekannt (Benutzer)'; - return ``; - }) - .join(''); - devSel.innerHTML = - ''; - 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 = ''; - devSel.innerHTML = ''; - devSel.disabled = true; - if (hint) hint.textContent = e.message || 'Fehler'; - } -} - -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' || name === 'callbackNumber')) - req = true; - if (v === 'REMOTE' && name === 'description_remote') req = false; - if (v === 'PART' && name === 'articleNumber') req = true; - } - inp.required = req; - }); - }); - if (v === 'REMOTE') { - loadTeamViewerConnectionsIntoSelect(); - } -} - -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; -} - -function esc(s) { - return String(s ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -function extrasName(m) { - const x = m?.extras; - if (!x || typeof x !== 'object') return ''; - return String(x.Name || '').trim(); -} - -function parseRoute() { - const pathOnly = (location.hash.slice(1) || '/home').split('?')[0]; - const raw = pathOnly.replace(/^\/+/, ''); - const parts = raw.split('/').filter(Boolean); - return { parts }; -} - -function formatDateTime(iso) { - try { - return new Date(iso).toLocaleString('de-DE'); - } catch { - return '—'; - } -} - -const appEl = document.getElementById('app'); - -function updateNav(st) { - const nav = document.getElementById('main-nav'); - if (!nav) return; - if (!st.loggedIn) { - nav.innerHTML = ''; - return; - } - const isAdmin = st.user?.role === 'admin'; - nav.innerHTML = ` - Start - Maschinen - Tickets - ${isAdmin ? 'OptionenBenutzer' : ''} - ${esc(st.user.username)} - `; - const btn = document.getElementById('btn-logout'); - if (btn) { - btn.onclick = async () => { - try { - await apiPost('/auth/logout', {}); - } catch { - /* ignore */ - } - updateNav({ loggedIn: false }); - location.hash = '#/login'; - }; - } -} - -async function viewBootstrap() { - appEl.innerHTML = ` -
-

Erster Administrator

-

Es ist noch kein Benutzer angelegt. Legen Sie das erste Admin-Konto an (min. 8 Zeichen Passwort).

-
-
- - - -
-
-
`; - 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.hash = '#/home'; - }; -} - -async function viewLogin() { - appEl.innerHTML = ` -
-

Anmelden

-
-
- - - -
-
-
`; - 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.hash = '#/home'; - } catch (err) { - appEl.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); - } - }; -} - -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 '—'; - } -} - -async function viewOptions() { - const data = await apiGet('/settings/integrations'); - let syncStatus = { lastSyncAt: null, entries: [] }; - try { - syncStatus = await apiGet('/ldap/sync-status'); - } catch { - /* z. B. ältere Server ohne Route */ - } - const ldap = data.ldap || {}; - const L = (k) => esc(ldap[k] ?? ''); - const userFilterVal = esc(ldap.userSearchFilter || ldap.userFilter || ''); - const syncMin = ldap.syncIntervalMinutes ?? 1440; - const tv = data.teamviewer || {}; - const bearer = esc(tv.bearerToken || tv.apiToken || ''); - const tvNotes = esc(tv.notes ?? tv.apiNotes ?? ''); - const lastSyncLabel = formatDeSyncDateTime(syncStatus.lastSyncAt); - const logEntries = Array.isArray(syncStatus.entries) ? syncStatus.entries : []; - const logRows = - logEntries.length > 0 - ? logEntries - .map( - (e) => ` - - ${esc(formatDeSyncDateTime(e.finishedAt))} - ${e.triggerType === 'automatic' ? 'Automatisch' : 'Manuell'} - ${e.status === 'success' ? 'Erfolg' : 'Fehler'} - ${esc(String(e.usersSynced ?? 0))} - ${e.errorMessage ? esc(e.errorMessage) : '—'} - `, - ) - .join('') - : `Noch keine Einträge.`; - - appEl.innerHTML = ` -
-

← Start

-

Optionen

-

Integrationen für das CRM. Werte werden in der Datenbank gespeichert.

-
-
- -
-

LDAP-Konfiguration

- -
- - - - -
- -
- - - -
- -

0 = nur manuelle Synchronisation

-
-
-
-

Synchronisation

-
- -
-

Letzte Synchronisation: ${esc(lastSyncLabel)}

-

Sync-Log (letzte 10 Einträge)

-
- - - - - - - - - - - ${logRows} -
ZeitpunktTypStatusBenutzer synchronisiertFehlermeldung
-
-
-
-

TeamViewer

-

API-Aufrufe nutzen den Header Authorization: Bearer <token> (nur das Token ohne das Wort „Bearer“ eintragen).

- - -
-
- -
-
-
`; - - 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', {}); - await route(); - } 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'), - }, - }); - route(); - }; -} - -async function viewUsers() { - const users = await apiGet('/users'); - appEl.innerHTML = ` -
-

← Start

-

Benutzer

-
-

Neuer Benutzer

-
-
- - - - -
-
-
-
- - - - - - - - - - - - ${users - .map( - (u) => ` - - - - - - - `, - ) - .join('')} - -
BenutzerRolleQuelleAktiv
${esc(u.username)}${u.role === 'admin' ? 'Admin' : 'Benutzer'}${u.source === 'ldap' ? 'LDAP' : 'Lokal'}${u.active ? 'Ja' : 'Nein'} - ${u.source === 'local' ? `` : ''} - - -
-
-
`; - - 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(); - route(); - }; - - appEl.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 }); - route(); - }; - }); - appEl.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 }); - route(); - }; - }); - appEl.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}`); - route(); - }; - }); -} - -async function route() { - const { parts } = parseRoute(); - appEl.innerHTML = '

Lade …

'; - - let st; - try { - st = await authFetchStatus(); - } catch { - st = { needsBootstrap: false, loggedIn: false, user: null }; - } - updateNav(st); - - try { - if (st.needsBootstrap) { - if (parts[0] !== 'bootstrap') { - location.hash = '#/bootstrap'; - return; - } - await viewBootstrap(); - return; - } - if (!st.loggedIn) { - if (parts[0] !== 'login') { - location.hash = '#/login'; - return; - } - await viewLogin(); - return; - } - if (parts[0] === 'login' || parts[0] === 'bootstrap') { - location.hash = '#/home'; - return; - } - - const isAdmin = st.user?.role === 'admin'; - if (parts[0] === 'options' && parts.length === 1) { - if (!isAdmin) { - location.hash = '#/home'; - return; - } - await viewOptions(); - return; - } - if (parts[0] === 'users' && parts.length === 1) { - if (!isAdmin) { - location.hash = '#/home'; - return; - } - await viewUsers(); - return; - } - - if (parts[0] === 'home' && parts.length === 1) { - await viewHome(); - return; - } - if (parts[0] === 'machines' && parts.length === 1) { - await viewMachineList(); - return; - } - if (parts[0] === 'machines' && parts.length === 2) { - await viewMachineDetail(parts[1]); - return; - } - if (parts[0] === 'tickets' && parts.length === 1) { - await viewTicketList(); - return; - } - if (parts[0] === 'tickets' && parts.length === 2) { - await viewTicketDetail(parts[1]); - return; - } - location.hash = '#/home'; - } catch (e) { - if (isAuthRedirectError(e)) { - updateNav({ loggedIn: false, user: null, needsBootstrap: false }); - return; - } - appEl.innerHTML = `

${esc(e.message)}

`; - } -} - -/** Einheitlicher Platzhalter, wenn noch keine Gruppenzeile gesetzt war */ -const ANLAGEN_GRUPPE_PLACEHOLDER = '\u2014'; - -/** Baut das extras-Objekt aus dem Maschinen-Bearbeitungsformular (Anlagenliste). */ -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; -} - -function extrasTableHtml(extras, opts) { - const editable = opts && opts.editable === true; - if (!extras || typeof extras !== 'object') { - if (editable) { - return ` -
-

Anlagenliste (ITT)

-

Keine Anlagendaten gespeichert — nichts zu bearbeiten.

-
`; - } - 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 ` - ${esc(grp)} - ${esc(String(label || `Spalte ${i + 1}`))} - - `; - }) - .join(''); - return ` -
-

Anlagenliste (bearbeiten)

-

Nur die Spalte „Wert“ ist bearbeitbar. Gruppe und Beschreibung bleiben wie importiert.

-
- - - - - - - - - - - - - - ${body} -
Gruppe (Z. 7)Beschreibung (Z. 9)Wert
-
-
`; - } - - /** Zeilen mit gleicher laufender Gruppe; Titelzeile nur bei Wechsel */ - 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 - ? `` - : ''; - const titel = gruppenTitel(seg.key); - const kopf = `${esc(titel)}`; - 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( - `${esc(label)}${esc(String(v ?? ''))}`, - ); - } - return `${spacer}${kopf}${zeilen.join('')}`; - }) - .join(''); - - return ` -
-

Anlagenliste (alle Spalten · Zeile 9 = Beschreibung)

-

Gruppe = Zeile 7 im Blatt „Anlagen“, Beschreibung = Zeile 9. Zusammenhängende Zeilen mit gleicher Gruppe sind zusammengefasst.

-
- - - - - - - - - - - - ${tbodies} -
Beschreibung (Z. 9)Wert
-
-
`; - } - - const body = beschr - .map((label, i) => { - const v = werte[i]; - const show = v !== '' && v != null; - const grp = ''; - return ` - ${esc(grp)} - ${esc(String(label || `Spalte ${i + 1}`))} - ${esc(String(v ?? ''))} - `; - }) - .join(''); - return ` -
-

Anlagenliste (alle Spalten · Zeile 9 = Beschreibung)

-

Gruppe = Zeile 7 im Blatt „Anlagen“, Beschreibung = Zeile 9.

-
- - - - - - - - - - - - - - ${body} -
Gruppe (Z. 7)Beschreibung (Z. 9)Wert
-
-
`; - } - - const kvEntries = Object.entries(extras).filter(([k]) => !k.startsWith('_')); - if (editable) { - if (kvEntries.length === 0) { - return ` -
-

Daten aus Anlagenliste (ITT)

-

Keine zusätzlichen Felder — nichts zu bearbeiten.

-
`; - } - const rows = kvEntries - .map( - ([k, v], i) => - ` - ${esc(k)} - - `, - ) - .join(''); - return ` -
-

Daten aus Anlagenliste (ITT)

-

Nur die Werte sind bearbeitbar; Feldnamen sind fest.

-
- - ${rows} -
-
-
`; - } - - const rows = kvEntries - .filter(([, v]) => v !== '' && v != null) - .map( - ([k, v]) => - `${esc(k)}${esc(String(v))}`, - ) - .join(''); - if (!rows) return ''; - return ` -
-

Daten aus Anlagenliste (ITT)

-
- - ${rows} -
-
-
`; -} - -async function viewMachineList() { - const machines = await apiGet('/machines'); - appEl.innerHTML = ` -
-

Maschinen (Anlagenliste)

-

${machines.length} Einträge · Import: npm run import:anlagen

-
- -
-
- - - - - - - - - - - - - - ${machines - .map((m) => { - const x = m.extras || {}; - return ` - - - - - - - - - `; - }) - .join('')} - -
Seriennr.TypKonzernName (Excel)StadtLandJahr
${esc(m.seriennummer)}${esc(m.typ)}${esc(x.Konzern || '')}${esc(x.Name || '')}${esc(x.Stadt || '')}${esc(x.Land || '')}${esc(x.Jahr || '')}
-
-
`; - - const inp = document.getElementById('machine-filter'); - const tbody = document.querySelector('#machine-table tbody'); - inp.addEventListener('input', () => { - const q = inp.value.toLowerCase().trim(); - tbody.querySelectorAll('tr').forEach((tr) => { - tr.hidden = !!(q && !tr.textContent.toLowerCase().includes(q)); - }); - }); -} - -async function viewMachineDetail(id) { - const m = await apiGet(`/machines/${id}`); - const ortName = extrasName(m); - - const renderView = () => { - appEl.innerHTML = ` -
-

← Alle Maschinen

-

${esc(m.name)}

-
-

Typ: ${esc(m.typ)}

-

Seriennummer: ${esc(m.seriennummer)}

-

Standort: ${esc(m.standort)}

- ${ortName ? `

Name (Anlagenliste): ${esc(ortName)}

` : ''} -
- - Tickets dieser Maschine - -
-
- ${extrasTableHtml(m.extras)} -
`; - - document.getElementById('btn-m-del').onclick = async () => { - if (!confirm('Maschine wirklich löschen?')) return; - await apiDelete(`/machines/${id}`); - location.hash = '#/machines'; - }; - - document.getElementById('btn-m-edit').onclick = () => renderEdit(); - }; - - const renderEdit = () => { - appEl.innerHTML = ` -
-

← Alle Maschinen

-

${esc(m.name)}

-
-
-

Stammdaten

-
- - -
-
- - -
-
- ${extrasTableHtml(m.extras, { editable: true })} -
- - - Tickets dieser Maschine - -
-
-
`; - - const form = document.getElementById('form-m'); - form.onsubmit = async (e) => { - e.preventDefault(); - const body = { - name: form.elements.name.value, - typ: form.elements.typ.value, - seriennummer: form.elements.seriennummer.value, - standort: form.elements.standort.value, - extras: collectExtrasFromMachineForm(form, m), - }; - await apiPut(`/machines/${id}`, body); - route(); - }; - document.getElementById('m-cancel').onclick = () => renderView(); - document.getElementById('btn-m-del-edit').onclick = async () => { - if (!confirm('Maschine wirklich löschen?')) return; - await apiDelete(`/machines/${id}`); - location.hash = '#/machines'; - }; - }; - - renderView(); -} - -const statusBadgeClass = { OPEN: 'badge-open', WAITING: 'badge-waiting', DONE: 'badge-done' }; -const priorityBadgeClass = { HIGH: 'badge-high', MEDIUM: 'badge-medium', LOW: 'badge-low' }; - -async function viewHome() { - 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; - - const listHtml = - tickets.length === 0 - ? '

Keine offenen Tickets.

' - : `
${tickets - .map((t, i) => { - const events = eventsLists[i] || []; - 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 mBlock = t.machine - ? `

Maschine: ${esc(t.machine.seriennummer)}${mn ? ` · ${esc(mn)}` : ''}  ·  Typ: ${esc(t.machine.typ)}

` - : ''; - const evChrono = [...events].reverse(); - const eventBoxes = - evChrono.length === 0 - ? '

Noch keine Ereignisse in der Historie.

' - : evChrono - .map( - (ev) => ` -
-
- ${esc(eventTypeLabel[ev.type] || ev.type)} - -
-
${eventInhaltHtml(ev)}
-
`, - ) - .join(''); - return ` -
-
-
-
- ${esc(t.title)} - ${esc(ticketStatusLabel[t.status])} - ${esc(ticketPriorityLabel[t.priority])} -
-
- ${t.machine ? `Maschine: ${machineLabel}` : 'Keine Maschine'} - Standort: ${standort} - Erstellt: ${esc(formatDateTime(t.createdAt))} - Aktualisiert: ${esc(formatDateTime(t.updatedAt))} -
-
- Ticket öffnen → -
-
-

Beschreibung

-

${esc(t.description)}

- ${mBlock} -
-
${eventBoxes}
-
`; - }) - .join('')}
`; - - appEl.innerHTML = ` -
-
-

Offene Tickets

-
- ${openCount} Offen - ${waitingCount} Wartend - gesamt: ${tickets.length} -
-
- ${listHtml} -
`; -} - -function ticketListQuery() { - const q = new URLSearchParams(location.hash.split('?')[1] || ''); - 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}` : ''; -} - -async function viewTicketList() { - const qs = ticketListQuery(); - const hashParams = new URLSearchParams(location.hash.split('?')[1] || ''); - - const [tickets, allMachines] = await Promise.all([ - apiGet(`/tickets${qs}`), - apiGet('/machines'), - ]); - - const mid = hashParams.get('machineId') || ''; - - appEl.innerHTML = ` -
-

Tickets

-
-

Neues Ticket

-
-
- -
-
- - -
- -
-
-
-

Filter

-
-
- - - -
- -
-
-
- - - - ${tickets - .map( - (t) => ` - - - - - - `, - ) - .join('')} - -
TitelStatusPrioritätMaschine
${esc(t.title)}${esc(ticketStatusLabel[t.status])}${esc(ticketPriorityLabel[t.priority])}${t.machine ? esc(t.machine.seriennummer) : ''}${t.machine ? (extrasName(t.machine) ? ` · ${esc(extrasName(t.machine))}` : '') : ''}
-
-
`; - - const selFm = document.getElementById('sel-fm'); - if (mid) selFm.value = mid; - - 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(); - route(); - }; - - 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.hash = `#/tickets${q ? `?${q}` : ''}`; - }; -} - -async function viewTicketDetail(id) { - const [ticket, events] = await Promise.all([ - apiGet(`/tickets/${id}`), - apiGet(`/tickets/${id}/events`), - ]); - - const mn = ticket.machine ? extrasName(ticket.machine) : ''; - - appEl.innerHTML = ` -
-

← Zurück

-

${esc(ticket.title)}

-
-

Status: ${esc(ticketStatusLabel[ticket.status])}

-

Priorität: ${esc(ticketPriorityLabel[ticket.priority])}

-

Beschreibung:

-

${esc(ticket.description)}

- ${ticket.machine ? `

Maschine: ${esc(ticket.machine.seriennummer)}${mn ? ` · ${esc(mn)}` : ''}

` : ''} - -
-

Historie

-
-

Event hinzufügen

-
- -
- -
- - - - -
-
-
- - - - - - - - - - ${ - events.length === 0 - ? `` - : events - .map( - (ev) => ` - - - - - `, - ) - .join('') - } - -
ZeitpunktArtInhalt
Noch keine Ereignisse.
${esc(formatDateTime(ev.createdAt))}${esc(eventTypeLabel[ev.type] || ev.type)}${eventInhaltHtml(ev)}
-
-

Weiteres Ticket für diese Maschine

-

Optional.

-
-
-
- - -
- -
-
-
`; - - 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); - 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 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', - '

Beschreibung oder Gerät auswählen.

', - ); - setTimeout(() => { - document.querySelector('.tv-form-err')?.remove(); - }, 4000); - return; - } - } - } - await apiPost('/events', body); - e.target.reset(); - syncEventFormFieldGroups(formEv); - route(); - }; - - document.getElementById('form-t2').onsubmit = async (e) => { - e.preventDefault(); - const fd = new FormData(e.target); - await apiPost('/tickets', { - machineId: ticket.machineId, - title: fd.get('title'), - description: fd.get('description'), - }); - e.target.reset(); - route(); - }; - - document.getElementById('btn-t-edit').onclick = () => { - document.getElementById('tick-view').innerHTML = ` -
- - -
- - -
-
- - -
-
`; - document.getElementById('form-tu').onsubmit = async (e) => { - e.preventDefault(); - const fd = new FormData(e.target); - await apiPut(`/tickets/${id}`, Object.fromEntries(fd.entries())); - route(); - }; - document.getElementById('tu-cancel').onclick = () => route(); - }; -} - -window.addEventListener('unhandledrejection', (ev) => { - if (isAuthRedirectError(ev.reason)) { - ev.preventDefault(); - } -}); - -window.addEventListener('hashchange', route); -if (!location.hash) location.hash = '#/home'; -route(); diff --git a/public/js/core/attachment-preview.js b/public/js/core/attachment-preview.js new file mode 100644 index 0000000..d22410a --- /dev/null +++ b/public/js/core/attachment-preview.js @@ -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 = ` +
+
+

+ +
+
+ +
`; + 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 = '

Lade Vorschau …

'; + + 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 = + '

Für diesen Dateityp gibt es keine eingebaute Vorschau. Nutzen Sie „Herunterladen“.

'; + } + } catch (err) { + bodyEl.className = 'attachment-preview-body attachment-preview-body--scroll'; + bodyEl.innerHTML = `

Vorschau konnte nicht geladen werden: ${err.message || err}

`; + } + + if (typeof dialogEl.showModal === 'function') { + setPageScrollLocked(true); + dialogEl.showModal(); + } +} diff --git a/public/js/core/auth-guard.js b/public/js/core/auth-guard.js new file mode 100644 index 0000000..696baf6 --- /dev/null +++ b/public/js/core/auth-guard.js @@ -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} 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; +} diff --git a/public/js/core/constants.js b/public/js/core/constants.js new file mode 100644 index 0000000..f2a422e --- /dev/null +++ b/public/js/core/constants.js @@ -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', +}; diff --git a/public/js/core/layout.js b/public/js/core/layout.js new file mode 100644 index 0000000..f45b3b2 --- /dev/null +++ b/public/js/core/layout.js @@ -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 = ` + Start + Maschinen + Tickets + ${ + isAdmin + ? `OptionenBenutzer` + : '' + } + ${esc(st.user.username)} + `; + 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'; + }; + } +} diff --git a/public/js/core/machine-extras.js b/public/js/core/machine-extras.js new file mode 100644 index 0000000..ffad6f3 --- /dev/null +++ b/public/js/core/machine-extras.js @@ -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 ` +
+

Anlagenliste (ITT)

+

Keine Anlagendaten gespeichert — nichts zu bearbeiten.

+
`; + } + 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 ` + ${esc(grp)} + ${esc(String(label || `Spalte ${i + 1}`))} + + `; + }) + .join(''); + return ` +
+

Anlagenliste (bearbeiten)

+

Nur die Spalte „Wert“ ist bearbeitbar. Gruppe und Beschreibung bleiben wie importiert.

+
+ + + + + + + + + + + + + + ${body} +
Gruppe (Z. 7)Beschreibung (Z. 9)Wert
+
+
`; + } + + 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 + ? `` + : ''; + const titel = gruppenTitel(seg.key); + const kopf = `${esc(titel)}`; + 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( + `${esc(label)}${esc(String(v ?? ''))}`, + ); + } + return `${spacer}${kopf}${zeilen.join('')}`; + }) + .join(''); + + return ` +
+

Anlagenliste

+
+ + + + + + + + + + + + ${tbodies} +
Beschreibung (Z. 9)Wert
+
+
`; + } + + const body = beschr + .map((label, i) => { + const v = werte[i]; + const show = v !== '' && v != null; + const grp = ''; + return ` + ${esc(grp)} + ${esc(String(label || `Spalte ${i + 1}`))} + ${esc(String(v ?? ''))} + `; + }) + .join(''); + return ` +
+

Anlagenliste (alle Spalten · Zeile 9 = Beschreibung)

+

Gruppe = Zeile 7 im Blatt „Anlagen“, Beschreibung = Zeile 9.

+
+ + + + + + + + + + + + + + ${body} +
Gruppe (Z. 7)Beschreibung (Z. 9)Wert
+
+
`; + } + + const kvEntries = Object.entries(extras).filter(([k]) => !k.startsWith('_')); + if (editable) { + if (kvEntries.length === 0) { + return ` +
+

Daten aus Anlagenliste (ITT)

+

Keine zusätzlichen Felder — nichts zu bearbeiten.

+
`; + } + const rows = kvEntries + .map( + ([k, v], i) => + ` + ${esc(k)} + + `, + ) + .join(''); + return ` +
+

Daten aus Anlagenliste (ITT)

+

Nur die Werte sind bearbeitbar; Feldnamen sind fest.

+
+ + ${rows} +
+
+
`; + } + + const rows = kvEntries + .filter(([, v]) => v !== '' && v != null) + .map( + ([k, v]) => + `${esc(k)}${esc(String(v))}`, + ) + .join(''); + if (!rows) return ''; + return ` +
+

Daten aus Anlagenliste (ITT)

+
+ + ${rows} +
+
+
`; +} diff --git a/public/js/core/ticket-events.js b/public/js/core/ticket-events.js new file mode 100644 index 0000000..c85f8f8 --- /dev/null +++ b/public/js/core/ticket-events.js @@ -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 = `

Beschreibung

${esc(ev.description)}
`; + if (ev.callbackNumber) { + const th = telHref(ev.callbackNumber); + const numHtml = th + ? `${esc(ev.callbackNumber)}` + : esc(ev.callbackNumber); + h += `

Rückrufnummer: ${numHtml}

`; + } + return `${h}
`; + } + if (t === 'REMOTE') { + let h = `

Beschreibung

${esc(ev.description)}
`; + if (ev.teamviewerId) { + h += `

Gerät-ID (TeamViewer): ${esc(ev.teamviewerId)}

`; + } + if (ev.remoteDurationSeconds != null) { + h += `

Remote-Dauer: ${esc(formatRemoteDurationDe(ev.remoteDurationSeconds))}

`; + } + if (ev.teamviewerNotes && String(ev.teamviewerNotes).trim()) { + h += `

Notizen: ${esc(String(ev.teamviewerNotes).trim())}

`; + } + return `${h}
`; + } + if (t === 'PART') { + let h = `

Artikelnummer: ${esc(ev.articleNumber || '')}

`; + if (ev.description && String(ev.description).trim()) { + h += `

Bemerkung

${esc(ev.description)}
`; + } + return `${h}
`; + } + if (t === 'ATTACHMENT') { + let h = ''; + if (ev.description && String(ev.description).trim()) { + h += `

Beschreibung

${esc(ev.description)}
`; + } + const atts = ev.attachments || []; + if (atts.length === 0) { + h += '

Keine Dateien.

'; + } else { + h += ''; + } + return h; + } + return `
${esc(ev.description)}
`; +} + +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 = + ''; + 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) => + ``, + ) + .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 = ''; + devSel.innerHTML = ''; + devSel.disabled = true; + if (hint) hint.textContent = ''; + try { + const data = await apiGet('/integrations/teamviewer/connections'); + tvSessionsCache = data; + const users = data.users || []; + userSel.innerHTML = + '' + + users + .map((u) => { + const label = + u.username && u.username !== '_unbekannt' + ? u.username + : u.userid + ? `Benutzer ${u.userid}` + : 'Unbekannt (Benutzer)'; + return ``; + }) + .join(''); + devSel.innerHTML = + ''; + 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 = ''; + devSel.innerHTML = ''; + 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; +} diff --git a/public/js/core/utils.js b/public/js/core/utils.js new file mode 100644 index 0000000..b0e5955 --- /dev/null +++ b/public/js/core/utils.js @@ -0,0 +1,60 @@ +export function esc(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** Rufnummer für href="tel:…" (Ziffern, höchstens ein führendes +). */ +export function telHref(raw) { + const t = String(raw ?? '').trim(); + if (!t) return ''; + const digitsPlus = t.replace(/[^\d+]/g, ''); + if (!digitsPlus) return ''; + const normalized = digitsPlus.startsWith('+') + ? '+' + digitsPlus.slice(1).replace(/\+/g, '') + : digitsPlus.replace(/\+/g, ''); + return `tel:${normalized}`; +} + +export function formatDateTime(iso) { + try { + return new Date(iso).toLocaleString('de-DE'); + } catch { + return '—'; + } +} + +export function formatDeSyncDateTime(iso) { + if (!iso) return '—'; + try { + return new Date(iso).toLocaleString('de-DE', { + day: 'numeric', + month: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } catch { + return '—'; + } +} + +export function extrasName(m) { + const x = m?.extras; + if (!x || typeof x !== 'object') return ''; + return String(x.Name || '').trim(); +} + +/** Anzeige Dauer aus gespeicherten Sekunden (TeamViewer start/end). */ +export function formatRemoteDurationDe(totalSec) { + if (totalSec == null || totalSec < 0) return ''; + const n = Math.floor(Number(totalSec)); + const m = Math.floor(n / 60); + const s = n % 60; + if (m === 0) return `${s} Sek.`; + if (s === 0) return `${m} Min.`; + return `${m} Min. ${s} Sek.`; +} diff --git a/public/js/pages/bootstrap.js b/public/js/pages/bootstrap.js new file mode 100644 index 0000000..5fb1bc3 --- /dev/null +++ b/public/js/pages/bootstrap.js @@ -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(); diff --git a/public/js/pages/login.js b/public/js/pages/login.js new file mode 100644 index 0000000..4d07dd9 --- /dev/null +++ b/public/js/pages/login.js @@ -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(); diff --git a/public/js/pages/machine-detail.js b/public/js/pages/machine-detail.js new file mode 100644 index 0000000..803b48d --- /dev/null +++ b/public/js/pages/machine-detail.js @@ -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(); diff --git a/public/js/pages/machines.js b/public/js/pages/machines.js new file mode 100644 index 0000000..e3823b7 --- /dev/null +++ b/public/js/pages/machines.js @@ -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 ` + ${esc(m.seriennummer)} + ${esc(m.typ)} + ${esc(x.Konzern || '')} + ${esc(x.Name || '')} + ${esc(x.Stadt || '')} + ${esc(x.Land || '')} + ${esc(x.Jahr || '')} + `; + }) + .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(); diff --git a/public/js/pages/options.js b/public/js/pages/options.js new file mode 100644 index 0000000..51d7b0d --- /dev/null +++ b/public/js/pages/options.js @@ -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 = + 'Noch keine Einträge.'; + return; + } + tbody.innerHTML = entries + .map( + (e) => ` + + ${esc(formatDeSyncDateTime(e.finishedAt))} + ${e.triggerType === 'automatic' ? 'Automatisch' : 'Manuell'} + ${e.status === 'success' ? 'Erfolg' : 'Fehler'} + ${esc(String(e.usersSynced ?? 0))} + ${e.errorMessage ? esc(e.errorMessage) : '—'} + `, + ) + .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(); diff --git a/public/js/pages/start.js b/public/js/pages/start.js new file mode 100644 index 0000000..0e8d499 --- /dev/null +++ b/public/js/pages/start.js @@ -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 '

Noch keine Ereignisse in der Historie.

'; + } + return evChrono + .map( + (ev) => ` +
+
+ ${esc(eventTypeLabel[ev.type] || ev.type)} + +
+
${eventInhaltHtml(ev)}
+
`, + ) + .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 + ? `Maschine: ${machineLabel}` + : 'Keine Maschine'; + + 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 = `

Maschine: ${esc(m.seriennummer)}${mn ? ` · ${esc(mn)}` : ''}  ·  Typ: ${esc(m.typ)}

`; + } 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(); diff --git a/public/js/pages/ticket-detail.js b/public/js/pages/ticket-detail.js new file mode 100644 index 0000000..5a4ce9f --- /dev/null +++ b/public/js/pages/ticket-detail.js @@ -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 = + 'Noch keine Ereignisse.'; + return; + } + tbody.innerHTML = events + .map( + (ev) => ` + + ${esc(formatDateTime(ev.createdAt))} + ${esc(eventTypeLabel[ev.type] || ev.type)} + ${eventInhaltHtml(ev)} + `, + ) + .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', + '

Mindestens eine Datei auswählen.

', + ); + 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', + '

Beschreibung oder Gerät auswählen.

', + ); + 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(); diff --git a/public/js/pages/tickets.js b/public/js/pages/tickets.js new file mode 100644 index 0000000..f8cda98 --- /dev/null +++ b/public/js/pages/tickets.js @@ -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) => + ``, + ) + .join(''); + selNm.innerHTML = `${opts}`; + selFm.innerHTML = `${opts}`; + if (selectedMid) { + selNm.value = selectedMid; + selFm.value = selectedMid; + } +} + +function renderTicketRows(tickets) { + const tbody = document.getElementById('tickets-table-body'); + tbody.innerHTML = tickets + .map( + (t) => ` + + ${esc(t.title)} + ${esc(ticketStatusLabel[t.status])} + ${esc(ticketPriorityLabel[t.priority])} + ${t.machine ? esc(t.machine.seriennummer) : ''}${t.machine ? (extrasName(t.machine) ? ` · ${esc(extrasName(t.machine))}` : '') : ''} + `, + ) + .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(); diff --git a/public/js/pages/users.js b/public/js/pages/users.js new file mode 100644 index 0000000..d6d95e8 --- /dev/null +++ b/public/js/pages/users.js @@ -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) => ` + + ${esc(u.username)} + ${u.role === 'admin' ? 'Admin' : 'Benutzer'} + ${u.source === 'ldap' ? 'LDAP' : 'Lokal'} + ${u.active ? 'Ja' : 'Nein'} + + ${u.source === 'local' ? `` : ''} + + + + `, + ) + .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(); diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..9210a41 --- /dev/null +++ b/public/login.html @@ -0,0 +1,30 @@ + + + + + + Anmelden — SDS CRM + + + + +
+

SDS CRM

+ +
+
+

Lade …

+ +
+ + + diff --git a/public/machine.html b/public/machine.html new file mode 100644 index 0000000..4bee611 --- /dev/null +++ b/public/machine.html @@ -0,0 +1,85 @@ + + + + + + Maschine — SDS CRM + + + + +
+

SDS CRM

+ +
+
+

Lade …

+ + + +
+ + + diff --git a/public/machines.html b/public/machines.html new file mode 100644 index 0000000..1a2686d --- /dev/null +++ b/public/machines.html @@ -0,0 +1,132 @@ + + + + + + Maschinen — SDS CRM + + + + +
+

SDS CRM

+ +
+
+

Lade …

+ + +
+ + + diff --git a/public/options.html b/public/options.html new file mode 100644 index 0000000..b2ac370 --- /dev/null +++ b/public/options.html @@ -0,0 +1,157 @@ + + + + + + Optionen — SDS CRM + + + + +
+

SDS CRM

+ +
+
+

Lade …

+ + +
+ + + diff --git a/public/start.html b/public/start.html new file mode 100644 index 0000000..b03c96e --- /dev/null +++ b/public/start.html @@ -0,0 +1,75 @@ + + + + + + Start — SDS CRM + + + + +
+

SDS CRM

+ +
+
+

Lade …

+ + +
+ + + + + + diff --git a/public/ticket.html b/public/ticket.html new file mode 100644 index 0000000..d8db023 --- /dev/null +++ b/public/ticket.html @@ -0,0 +1,182 @@ + + + + + + Ticket — SDS CRM + + + + +
+

SDS CRM

+ +
+
+

Lade …

+ + + +
+ + + diff --git a/public/tickets.html b/public/tickets.html new file mode 100644 index 0000000..d687452 --- /dev/null +++ b/public/tickets.html @@ -0,0 +1,96 @@ + + + + + + Tickets — SDS CRM + + + + +
+

SDS CRM

+ +
+
+

Lade …

+ + +
+ + + diff --git a/public/users.html b/public/users.html new file mode 100644 index 0000000..2edd086 --- /dev/null +++ b/public/users.html @@ -0,0 +1,59 @@ + + + + + + Benutzer — SDS CRM + + + + +
+

SDS CRM

+ +
+
+

Lade …

+ + +
+ + + diff --git a/scripts/import-anlagen-itt.mjs b/scripts/import-anlagen-itt.mjs index d8ee3e6..058c1d6 100644 --- a/scripts/import-anlagen-itt.mjs +++ b/scripts/import-anlagen-itt.mjs @@ -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; diff --git a/server/db.js b/server/db.js index bc882db..79ebe3a 100644 --- a/server/db.js +++ b/server/db.js @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { DatabaseSync } from 'node:sqlite'; import { fileURLToPath } from 'url'; +import { mergeLegacyAttachmentEventsByDay } from './lib/merge-attachment-events.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const dbPath = @@ -16,6 +17,12 @@ const machineCols = db.prepare('PRAGMA table_info(machines)').all(); if (!machineCols.some((c) => c.name === 'extras')) { db.exec('ALTER TABLE machines ADD COLUMN extras TEXT'); } +const machineCols2 = db.prepare('PRAGMA table_info(machines)').all(); +if (!machineCols2.some((c) => c.name === 'list_status')) { + db.exec( + "ALTER TABLE machines ADD COLUMN list_status TEXT NOT NULL DEFAULT ''", + ); +} const hasCustomerId = machineCols.some((c) => c.name === 'customer_id'); const tables = db @@ -30,6 +37,9 @@ const eventCols = db.prepare('PRAGMA table_info(events)').all(); if (eventCols.length > 0 && !eventCols.some((c) => c.name === 'remote_duration_seconds')) { db.exec('ALTER TABLE events ADD COLUMN remote_duration_seconds INTEGER'); } +if (eventCols.length > 0 && !eventCols.some((c) => c.name === 'teamviewer_notes')) { + db.exec('ALTER TABLE events ADD COLUMN teamviewer_notes TEXT'); +} const hasEventExtras = eventCols.some((c) => c.name === 'callback_number'); if (eventCols.length > 0 && !hasEventExtras) { db.exec('BEGIN'); @@ -44,10 +54,11 @@ if (eventCols.length > 0 && !hasEventExtras) { "teamviewer_id" TEXT, "article_number" TEXT, "remote_duration_seconds" INTEGER, + "teamviewer_notes" TEXT, "created_at" TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY ("ticket_id") REFERENCES "tickets" ("id") ON DELETE CASCADE ON UPDATE CASCADE ); - INSERT INTO events_new (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds, created_at) + INSERT INTO events_new (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds, teamviewer_notes, created_at) SELECT id, ticket_id, @@ -57,6 +68,7 @@ if (eventCols.length > 0 && !hasEventExtras) { NULL, NULL, NULL, + NULL, created_at FROM events; DROP TABLE events; @@ -85,12 +97,13 @@ if (hasCustomerId || hasCustomersTable) { "typ" TEXT NOT NULL, "seriennummer" TEXT NOT NULL, "standort" TEXT NOT NULL, + "list_status" TEXT NOT NULL DEFAULT '', "extras" TEXT, "created_at" TEXT NOT NULL DEFAULT (datetime('now')), "updated_at" TEXT NOT NULL DEFAULT (datetime('now')) ); - INSERT INTO machines_new (id, name, typ, seriennummer, standort, extras, created_at, updated_at) - SELECT id, name, typ, seriennummer, standort, extras, created_at, updated_at FROM machines; + INSERT INTO machines_new (id, name, typ, seriennummer, standort, list_status, extras, created_at, updated_at) + SELECT id, name, typ, seriennummer, standort, COALESCE(list_status, ''), extras, created_at, updated_at FROM machines; DROP TABLE machines; ALTER TABLE machines_new RENAME TO machines; `); @@ -160,4 +173,97 @@ if (!ldapLogTbl) { `); } +const ticketAttachmentsTbl = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='ticket_attachments'", + ) + .get(); +if (!ticketAttachmentsTbl) { + db.exec('BEGIN'); + try { + db.exec(` + CREATE TABLE events_new ( + "id" TEXT NOT NULL PRIMARY KEY, + "ticket_id" TEXT NOT NULL, + "type" TEXT NOT NULL CHECK ("type" IN ('NOTE', 'CALL', 'REMOTE', 'PART', 'SYSTEM', 'ATTACHMENT')), + "description" TEXT NOT NULL, + "callback_number" TEXT, + "teamviewer_id" TEXT, + "article_number" TEXT, + "remote_duration_seconds" INTEGER, + "teamviewer_notes" TEXT, + "created_at" TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY ("ticket_id") REFERENCES "tickets" ("id") ON DELETE CASCADE ON UPDATE CASCADE + ); + INSERT INTO events_new ( + id, ticket_id, type, description, callback_number, teamviewer_id, article_number, + remote_duration_seconds, teamviewer_notes, created_at + ) + SELECT + id, ticket_id, type, description, callback_number, teamviewer_id, article_number, + remote_duration_seconds, teamviewer_notes, created_at + FROM events; + DROP TABLE events; + ALTER TABLE events_new RENAME TO events; + `); + db.exec( + 'CREATE INDEX IF NOT EXISTS events_ticket_id_idx ON "events" ("ticket_id")', + ); + db.exec( + 'CREATE INDEX IF NOT EXISTS events_created_at_idx ON "events" ("created_at")', + ); + db.exec(` + CREATE TABLE "ticket_attachments" ( + "id" TEXT NOT NULL PRIMARY KEY, + "event_id" TEXT NOT NULL, + "original_name" TEXT NOT NULL, + "stored_path" TEXT NOT NULL, + "mime_type" TEXT, + "size_bytes" INTEGER NOT NULL, + "created_at" TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY ("event_id") REFERENCES "events" ("id") ON DELETE CASCADE ON UPDATE CASCADE + ); + CREATE INDEX IF NOT EXISTS ticket_attachments_event_idx ON "ticket_attachments" ("event_id"); + `); + db.exec('COMMIT'); + } catch (e) { + db.exec('ROLLBACK'); + throw e; + } +} + +const hasTicketAttachments = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='ticket_attachments'", + ) + .get(); +const attachmentMergeDone = db + .prepare('SELECT 1 AS ok FROM app_settings WHERE key = ?') + .get('attachment_events_merge_day_v1'); +if (hasTicketAttachments && !attachmentMergeDone) { + try { + mergeLegacyAttachmentEventsByDay(db); + db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run( + 'attachment_events_merge_day_v1', + '1', + ); + } catch (e) { + console.error('CRM: Zusammenführung Anhang-Events fehlgeschlagen:', e); + } +} + +const ticketCols = db.prepare('PRAGMA table_info(tickets)').all(); +if (!ticketCols.some((c) => c.name === 'sla_days')) { + db.exec('ALTER TABLE tickets ADD COLUMN sla_days INTEGER'); +} +if (!ticketCols.some((c) => c.name === 'sla_anchor_at')) { + db.exec('ALTER TABLE tickets ADD COLUMN sla_anchor_at TEXT'); +} +const ticketCols2 = db.prepare('PRAGMA table_info(tickets)').all(); +if (ticketCols2.some((c) => c.name === 'sla_anchor_at')) { + db.prepare( + 'UPDATE tickets SET sla_anchor_at = created_at WHERE sla_anchor_at IS NULL', + ).run(); +} + export default db; diff --git a/server/index.js b/server/index.js index 6611abe..36af725 100644 --- a/server/index.js +++ b/server/index.js @@ -1,17 +1,13 @@ -import { randomUUID } from 'crypto'; import cors from 'cors'; import dotenv from 'dotenv'; import express from 'express'; import session from 'express-session'; import path from 'path'; import { fileURLToPath } from 'url'; -import db from './db.js'; -import { getSyncStatus, performLdapSync } from './ldap-sync.js'; -import { hashPassword, verifyPassword } from './password.js'; -import { - computeRemoteDurationSeconds, - registerTeamViewerRoutes, -} from './teamviewer.js'; +import { restartLdapSyncScheduler } from './integrations.js'; +import { createApiRouter } from './routes/api/index.js'; +import { createAdminRouter } from './routes/admin/index.js'; +import authRouter from './routes/auth.js'; dotenv.config(); @@ -42,734 +38,21 @@ app.use( }), ); -const UUID = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +app.use('/auth', authRouter); +app.use('/api', createApiRouter()); +app.use('/api', createAdminRouter()); -function badRequest(res, msg) { - return res.status(400).json({ message: msg }); -} - -function parseJsonField(v) { - if (v == null) return undefined; - if (typeof v === 'object') return v; - return JSON.parse(v); -} - -function mapMachine(r) { - const o = { - id: r.id, - name: r.name, - typ: r.typ, - seriennummer: r.seriennummer, - standort: r.standort, - createdAt: r.created_at, - updatedAt: r.updated_at, - }; - if (r.extras != null && String(r.extras).trim() !== '') { - try { - o.extras = - typeof r.extras === 'string' ? JSON.parse(r.extras) : r.extras; - } catch { - o.extras = null; - } - } - return o; -} - -function mapTicket(r) { - const machine_row = parseJsonField(r.machine_row); - const t = { - id: r.id, - machineId: r.machine_id, - title: r.title, - description: r.description, - status: r.status, - priority: r.priority, - createdAt: r.created_at, - updatedAt: r.updated_at, - }; - if (machine_row) { - t.machine = mapMachine(machine_row); - } - return t; -} - -function mapEvent(r) { - return { - id: r.id, - ticketId: r.ticket_id, - type: r.type, - description: r.description, - createdAt: r.created_at, - callbackNumber: r.callback_number ?? null, - teamviewerId: r.teamviewer_id ?? null, - articleNumber: r.article_number ?? null, - remoteDurationSeconds: - r.remote_duration_seconds != null ? r.remote_duration_seconds : null, - }; -} - -function mapPublicUser(r) { - return { - id: r.id, - username: r.username, - role: r.role, - active: Boolean(r.active), - source: r.source, - ldapDn: r.ldap_dn || null, - createdAt: r.created_at, - updatedAt: r.updated_at, - }; -} - -const ticketJoinSelect = ` - SELECT t.*, - json_object( - 'id', m.id, - 'name', m.name, - 'typ', m.typ, - 'seriennummer', m.seriennummer, - 'standort', m.standort, - 'extras', m.extras, - 'created_at', m.created_at, - 'updated_at', m.updated_at - ) AS machine_row - FROM tickets t - JOIN machines m ON m.id = t.machine_id`; - -const DEFAULT_INTEGRATIONS = { - ldap: { - serverUrl: '', - bindDn: '', - bindPassword: '', - searchBase: '', - userSearchFilter: '', - userFilter: '', - usernameAttribute: 'sAMAccountName', - firstNameAttribute: 'givenName', - lastNameAttribute: 'sn', - syncIntervalMinutes: 1440, - syncEnabled: false, - syncNotes: '', - }, - /* TeamViewer: Authorization: Bearer */ - 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}`); diff --git a/server/integrations.js b/server/integrations.js new file mode 100644 index 0000000..b0171c2 --- /dev/null +++ b/server/integrations.js @@ -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); +} diff --git a/server/lib/http.js b/server/lib/http.js new file mode 100644 index 0000000..3ca69a9 --- /dev/null +++ b/server/lib/http.js @@ -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 }); +} diff --git a/server/lib/mappers.js b/server/lib/mappers.js new file mode 100644 index 0000000..cde0844 --- /dev/null +++ b/server/lib/mappers.js @@ -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`; diff --git a/server/lib/merge-attachment-events.js b/server/lib/merge-attachment-events.js new file mode 100644 index 0000000..a538d4a --- /dev/null +++ b/server/lib/merge-attachment-events.js @@ -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>} */ + 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; + } + } +} diff --git a/server/lib/ticket-events-merge.js b/server/lib/ticket-events-merge.js new file mode 100644 index 0000000..58a2967 --- /dev/null +++ b/server/lib/ticket-events-merge.js @@ -0,0 +1,86 @@ +import { mapEvent } from './mappers.js'; + +/** + * Mehrere ATTACHMENT-DB-Zeilen pro Kalendertag (lokal) zu einem Listeneintrag zusammenführen. + * @param {Array>} rows – SELECT * FROM events (bereits sortiert) + * @param {Map>>} 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>>} */ + 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; attachments: Array> }>} */ + 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, []), + ); +} diff --git a/server/middleware/auth.js b/server/middleware/auth.js new file mode 100644 index 0000000..29adb10 --- /dev/null +++ b/server/middleware/auth.js @@ -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(); +} diff --git a/server/routes/admin/index.js b/server/routes/admin/index.js new file mode 100644 index 0000000..72d632e --- /dev/null +++ b/server/routes/admin/index.js @@ -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; +} diff --git a/server/routes/api/attachments.js b/server/routes/api/attachments.js new file mode 100644 index 0000000..ef497ea --- /dev/null +++ b/server/routes/api/attachments.js @@ -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//` 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'; +} diff --git a/server/routes/api/events.js b/server/routes/api/events.js new file mode 100644 index 0000000..5289cab --- /dev/null +++ b/server/routes/api/events.js @@ -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; + } + }); +} diff --git a/server/routes/api/index.js b/server/routes/api/index.js new file mode 100644 index 0000000..8b8b321 --- /dev/null +++ b/server/routes/api/index.js @@ -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; +} diff --git a/server/routes/api/machines.js b/server/routes/api/machines.js new file mode 100644 index 0000000..db25761 --- /dev/null +++ b/server/routes/api/machines.js @@ -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)); + }); +} diff --git a/server/routes/api/tickets.js b/server/routes/api/tickets.js new file mode 100644 index 0000000..f8b9d4d --- /dev/null +++ b/server/routes/api/tickets.js @@ -0,0 +1,202 @@ +import { randomUUID } from 'crypto'; +import db from '../../db.js'; +import { badRequest, UUID } from '../../lib/http.js'; +import { mergeAttachmentEventsForApi } from '../../lib/ticket-events-merge.js'; +import { + mapTicket, + ticketJoinSelect, + ticketLastActivityExpr, + ticketSlaDueExpr, +} from '../../lib/mappers.js'; + +function parseSlaDaysForCreate(v) { + if (v === undefined || v === null || v === '') return null; + const n = Number(v); + if (!Number.isInteger(n) || n < 1 || n > 5) return undefined; + return n; +} + +/** Nur wenn Key gesetzt: null = Standard (2 Tage), 1–5 = Tage. */ +function parseSlaDaysForUpdate(v) { + if (v === null || v === '') return null; + const n = Number(v); + if (!Number.isInteger(n) || n < 1 || n > 5) return undefined; + return n; +} + +function slaDaysEqual(a, b) { + const na = a == null || a === '' ? null : Number(a); + const nb = b == null || b === '' ? null : Number(b); + return na === nb; +} + +const ticketListOrderBy = ` +ORDER BY + CASE WHEN t.status IN ('OPEN','WAITING') AND datetime('now') > ${ticketSlaDueExpr} THEN 0 ELSE 1 END ASC, + CASE WHEN t.status IN ('OPEN','WAITING') AND datetime('now') > ${ticketSlaDueExpr} THEN ${ticketSlaDueExpr} ELSE '9999-12-31' END ASC, + ${ticketLastActivityExpr} DESC`; + +export function registerTicketRoutes(api) { + api.get('/tickets', (req, res) => { + const { status, priority, machineId, open } = req.query; + const cond = ['1=1']; + const params = []; + const openFilter = open === '1' || open === 'true'; + if (openFilter) { + cond.push("t.status IN ('OPEN', 'WAITING')"); + } else if (status) { + cond.push('t.status = ?'); + params.push(status); + } + if (priority) { + cond.push('t.priority = ?'); + params.push(priority); + } + if (machineId) { + cond.push('t.machine_id = ?'); + params.push(machineId); + } + const sql = `${ticketJoinSelect} WHERE ${cond.join(' AND ')} ${ticketListOrderBy}`; + const rows = db.prepare(sql).all(...params); + res.json(rows.map(mapTicket)); + }); + + api.post('/tickets', (req, res) => { + const { machineId, title, description, status, priority, slaDays } = + req.body || {}; + if (!machineId || !title || !description) { + return badRequest(res, 'Pflichtfelder fehlen.'); + } + const m = db + .prepare('SELECT 1 AS ok FROM machines WHERE id = ?') + .get(machineId); + if (!m) return res.status(404).json({ message: 'Nicht gefunden' }); + const st = status || 'OPEN'; + const pr = priority || 'MEDIUM'; + const sd = parseSlaDaysForCreate(slaDays); + if (sd === undefined) { + return badRequest(res, 'slaDays ungültig (1–5 oder weglassen für Standard).'); + } + const tid = randomUUID(); + db.prepare( + `INSERT INTO tickets (id, machine_id, title, description, status, priority, sla_days, sla_anchor_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`, + ).run(tid, machineId, title, description, st, pr, sd); + const full = db + .prepare(`${ticketJoinSelect} WHERE t.id = ?`) + .get(tid); + res.status(201).json(mapTicket(full)); + }); + + api.get('/tickets/:id/events', (req, res) => { + const { id } = req.params; + if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); + const ex = db.prepare('SELECT 1 AS ok FROM tickets WHERE id = ?').get(id); + if (!ex) return res.status(404).json({ message: 'Nicht gefunden' }); + const rows = db + .prepare( + `SELECT * FROM events WHERE ticket_id = ? + ORDER BY CASE WHEN type = 'ATTACHMENT' THEN 1 ELSE 0 END ASC, + created_at DESC`, + ) + .all(id); + const eventIds = rows.map((r) => r.id); + const byEvent = new Map(); + if (eventIds.length > 0) { + const ph = eventIds.map(() => '?').join(','); + const attRows = db + .prepare( + `SELECT * FROM ticket_attachments WHERE event_id IN (${ph}) ORDER BY created_at ASC`, + ) + .all(...eventIds); + for (const a of attRows) { + if (!byEvent.has(a.event_id)) byEvent.set(a.event_id, []); + byEvent.get(a.event_id).push(a); + } + } + res.json(mergeAttachmentEventsForApi(rows, byEvent, db)); + }); + + api.get('/tickets/:id', (req, res) => { + const { id } = req.params; + if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); + const row = db.prepare(`${ticketJoinSelect} WHERE t.id = ?`).get(id); + if (!row) return res.status(404).json({ message: 'Nicht gefunden' }); + res.json(mapTicket(row)); + }); + + api.put('/tickets/:id', (req, res) => { + const { id } = req.params; + if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); + const cur = db.prepare('SELECT * FROM tickets WHERE id = ?').get(id); + if (!cur) return res.status(404).json({ message: 'Nicht gefunden' }); + const b = req.body || {}; + const next = { + title: b.title ?? cur.title, + description: b.description ?? cur.description, + status: b.status ?? cur.status, + priority: b.priority ?? cur.priority, + }; + + let nextSlaDays = cur.sla_days != null ? cur.sla_days : null; + let resetSlaAnchor = false; + if (Object.prototype.hasOwnProperty.call(b, 'slaDays')) { + const parsed = parseSlaDaysForUpdate(b.slaDays); + if (parsed === undefined) { + return badRequest( + res, + 'slaDays ungültig (1–5 oder leer für Standard).', + ); + } + nextSlaDays = parsed; + resetSlaAnchor = !slaDaysEqual(parsed, cur.sla_days); + } + + const lines = []; + if (b.status !== undefined && b.status !== cur.status) { + lines.push(`Status: ${cur.status} → ${b.status}`); + } + if (b.priority !== undefined && b.priority !== cur.priority) { + lines.push(`Priorität: ${cur.priority} → ${b.priority}`); + } + if (b.title !== undefined && b.title !== cur.title) lines.push('Titel geändert'); + if (b.description !== undefined && b.description !== cur.description) { + lines.push('Beschreibung geändert'); + } + if ( + Object.prototype.hasOwnProperty.call(b, 'slaDays') && + !slaDaysEqual(nextSlaDays, cur.sla_days) + ) { + const label = (d) => + d == null ? 'Standard (2 Tage)' : `${d} Tag(e)`; + lines.push( + `Fälligkeit: ${label(cur.sla_days)} → ${label(nextSlaDays)}`, + ); + } + if (lines.length > 0) { + const eid = randomUUID(); + db.prepare( + `INSERT INTO events (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds, teamviewer_notes) + VALUES (?, ?, 'SYSTEM', ?, NULL, NULL, NULL, NULL, NULL)`, + ).run(eid, id, lines.join('; ')); + } + + db.prepare( + `UPDATE tickets SET title = ?, description = ?, status = ?, priority = ?, sla_days = ?, + sla_anchor_at = CASE WHEN ? THEN datetime('now') ELSE sla_anchor_at END, + updated_at = datetime('now') + WHERE id = ?`, + ).run( + next.title, + next.description, + next.status, + next.priority, + nextSlaDays, + resetSlaAnchor ? 1 : 0, + id, + ); + + const row = db.prepare(`${ticketJoinSelect} WHERE t.id = ?`).get(id); + res.json(mapTicket(row)); + }); +} diff --git a/server/routes/auth.js b/server/routes/auth.js new file mode 100644 index 0000000..4f4c988 --- /dev/null +++ b/server/routes/auth.js @@ -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; diff --git a/server/teamviewer.js b/server/teamviewer.js index 088412c..86fe2bc 100644 --- a/server/teamviewer.js +++ b/server/teamviewer.js @@ -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) =>