Initial commit: Redmine ticket portal

This commit is contained in:
2026-07-13 13:26:37 +02:00
commit fb78c96e6b
8 changed files with 1416 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
# Kopieren nach .env und Werte anpassen. Ohne REDMINE_API_KEY startet der
# Server im Trockenlauf und simuliert die Ticket-Anlage (gut zum UI-Testen).
PORT=3000
REDMINE_URL=https://redmine.unicon-gmbh.de/redmine
REDMINE_API_KEY=
REDMINE_PROJECT_ID=1
REDMINE_TRACKER_ID=3
CUSTOMER_FIELD_ID=5
CUSTOMERS_FILE=./data/customers.csv
+21
View File
@@ -0,0 +1,21 @@
node_modules
# docs
.vscode
.github
.claude
.ai
.aiassistant
.env
*.7z
Thumbs.db
.DS_Store
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
+48
View File
@@ -0,0 +1,48 @@
# Redmine Ticket-Portal (Prototyp)
Vorgeschaltete Webseite fuer die Ticket-Anlage mit verpflichtendem Kundenbezug
(Loesungsvariante 1 aus dem Ticket). Die Kundenauswahl laeuft ueber ein
Autocomplete gegen die eigenen Stammdaten - die 1500 Kunden muessen also
nicht als Auswahlliste in Redmine gepflegt werden. Die Kundennummer wird in
ein Text-Custom-Field des Tickets geschrieben.
## Ablauf
1. Techniker sucht den Kunden per Nummer oder Firmenname (Autocomplete)
2. Betreff und Beschreibung erfassen
3. Der Server validiert die Kundennummer gegen die Stammdaten und legt das
Ticket per POST /issues.json in Redmine an (API-Key nur serverseitig)
## Setup
npm install
cp .env.example .env # Werte anpassen (API-Key, Projekt, Feld-ID)
npm start
Ohne gesetzten REDMINE_API_KEY laeuft der Server im Trockenlauf: die
Oberflaeche funktioniert komplett, Tickets werden aber nur simuliert.
Zum schnellen Ausprobieren reicht daher:
npm install && npm run start:dry
Danach http://localhost:3000 oeffnen.
## Konfiguration (.env)
| Variable | Bedeutung |
|---------------------|----------------------------------------------------|
| REDMINE_URL | Basis-URL der Redmine-Installation |
| REDMINE_API_KEY | API-Key eines technischen Users (Recht: Tickets anlegen) |
| REDMINE_PROJECT_ID | Ziel-Projekt fuer neue Tickets |
| REDMINE_TRACKER_ID | Tracker (z. B. Support) |
| CUSTOMER_FIELD_ID | ID des Custom Fields fuer die Kundennummer |
| CUSTOMERS_FILE | CSV mit den Kundenstammdaten (Nummer;Name) |
Die Feld-ID laesst sich per GET /custom_fields.json ermitteln.
## Naechste Schritte Richtung Produktion
- Kundenliste direkt aus CRM/ERP ziehen (DB-View oder Export per Cron)
- Authentifizierung fuer die Techniker (z. B. LDAP/SSO im Reverse Proxy)
- WS-Nummer als zweites Custom Field automatisch mitschreiben
- HTTPS und Betrieb hinter dem vorhandenen Reverse Proxy
+17
View File
@@ -0,0 +1,17 @@
# Format: Kundennummer;Firmenname (eine Zeile pro Kunde)
# In der Praxis: Export aus CRM/ERP, z. B. nachts per Cron aktualisiert
K-10001;Mueller Maschinenbau GmbH
K-10002;Schmidt & Partner Steuerberatung
K-10007;Autohaus Weber KG
K-10015;Stadtwerke Nordheim AöR
K-10042;Klinikum St. Elisabeth gGmbH
K-10058;Feinkost Brandt e.K.
K-10113;Logistik Hansen GmbH & Co. KG
K-10177;Bauunternehmung Krause AG
K-10201;Praxisverbund Dr. Sommer
K-10254;Druckerei Lindemann GmbH
K-10310;IT-Systemhaus Falke GmbH
K-10333;Hotel Seeblick Betriebs GmbH
K-10390;Spedition Voss International
K-10404;Metallbau Richter & Soehne
K-10466;Wohnungsbaugesellschaft Elbtal mbH
1 # Format: Kundennummer;Firmenname (eine Zeile pro Kunde)
2 # In der Praxis: Export aus CRM/ERP, z. B. nachts per Cron aktualisiert
3 K-10001;Mueller Maschinenbau GmbH
4 K-10002;Schmidt & Partner Steuerberatung
5 K-10007;Autohaus Weber KG
6 K-10015;Stadtwerke Nordheim AöR
7 K-10042;Klinikum St. Elisabeth gGmbH
8 K-10058;Feinkost Brandt e.K.
9 K-10113;Logistik Hansen GmbH & Co. KG
10 K-10177;Bauunternehmung Krause AG
11 K-10201;Praxisverbund Dr. Sommer
12 K-10254;Druckerei Lindemann GmbH
13 K-10310;IT-Systemhaus Falke GmbH
14 K-10333;Hotel Seeblick Betriebs GmbH
15 K-10390;Spedition Voss International
16 K-10404;Metallbau Richter & Soehne
17 K-10466;Wohnungsbaugesellschaft Elbtal mbH
+831
View File
@@ -0,0 +1,831 @@
{
"name": "redmine-ticket-portal",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "redmine-ticket-portal",
"version": "0.1.0",
"dependencies": {
"express": "^4.19.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"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/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "redmine-ticket-portal",
"version": "0.1.0",
"description": "Vorgeschaltete Webseite: Kundenauswahl per Autocomplete, Ticket-Anlage ueber die Redmine REST API",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node --env-file=.env server.js",
"start:dry": "node server.js"
},
"engines": { "node": ">=20" },
"dependencies": {
"express": "^4.19.0"
}
}
+321
View File
@@ -0,0 +1,321 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ticket anlegen - Service-Portal</title>
<style>
:root {
--ink: #1B2430;
--ink-soft: #5C6B7A;
--surface: #FFFFFF;
--canvas: #F5F7F8;
--line: #D7DEE3;
--accent: #0E7C7B;
--accent-dark: #0B6362;
--ok: #1D7A3E;
--bad: #B3261E;
--mono: ui-monospace, "SF Mono", "Cascadia Mono", Consolas, monospace;
}
* { box-sizing: border-box; margin: 0; }
body {
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--canvas);
color: var(--ink);
min-height: 100vh;
padding: 48px 16px;
}
main { max-width: 560px; margin: 0 auto; }
header { margin-bottom: 28px; }
header h1 { font-size: 1.45rem; font-weight: 650; letter-spacing: -0.01em; }
header p { color: var(--ink-soft); margin-top: 6px; font-size: 0.95rem; }
.step {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 10px;
padding: 20px;
margin-bottom: 16px;
}
.step[data-disabled="true"] { opacity: 0.45; pointer-events: none; }
.step-label {
display: flex; align-items: baseline; gap: 8px;
font-size: 0.8rem; font-weight: 650; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--ink-soft); margin-bottom: 12px;
}
.step-label .num { font-family: var(--mono); color: var(--accent); }
label { display: block; font-size: 0.9rem; font-weight: 600; margin: 14px 0 6px; }
label:first-of-type { margin-top: 0; }
input[type="text"], textarea {
width: 100%; font: inherit; color: inherit;
padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px;
background: var(--surface);
}
input[type="text"]:focus-visible, textarea:focus-visible, button:focus-visible {
outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent);
}
textarea { min-height: 110px; resize: vertical; }
/* Autocomplete */
.search-wrap { position: relative; }
#customerSearch { font-size: 1.05rem; padding: 13px 14px; }
.suggestions {
position: absolute; left: 0; right: 0; top: calc(100% + 4px); z-index: 10;
background: var(--surface); border: 1px solid var(--line); border-radius: 8px;
box-shadow: 0 8px 24px rgba(27, 36, 48, 0.12);
max-height: 320px; overflow-y: auto; display: none;
}
.suggestions.open { display: block; }
.suggestion {
display: flex; gap: 12px; align-items: baseline;
padding: 10px 14px; cursor: pointer; border: 0; width: 100%;
background: transparent; font: inherit; text-align: left;
}
.suggestion + .suggestion { border-top: 1px solid var(--canvas); }
.suggestion.active, .suggestion:hover { background: #E9F3F3; }
.suggestion .cnum { font-family: var(--mono); font-size: 0.85rem; color: var(--accent); white-space: nowrap; }
.suggestion-empty { padding: 12px 14px; color: var(--ink-soft); font-size: 0.9rem; }
/* Gewaehlter Kunde als Chip */
.customer-chip {
display: none; align-items: center; gap: 12px;
background: #E9F3F3; border: 1px solid var(--accent);
border-radius: 8px; padding: 12px 14px;
}
.customer-chip.visible { display: flex; }
.customer-chip .cnum { font-family: var(--mono); font-weight: 600; color: var(--accent); }
.customer-chip .cname { flex: 1; font-weight: 600; }
.customer-chip button {
border: 0; background: transparent; color: var(--ink-soft);
font: inherit; font-size: 0.85rem; cursor: pointer; text-decoration: underline;
}
.actions { display: flex; align-items: center; gap: 14px; margin-top: 18px; }
.submit {
font: inherit; font-weight: 650; color: #fff; background: var(--accent);
border: 0; border-radius: 8px; padding: 11px 22px; cursor: pointer;
}
.submit:hover { background: #0B6362; }
.submit:disabled { background: var(--line); color: var(--ink-soft); cursor: not-allowed; }
.feedback { margin-top: 14px; padding: 12px 14px; border-radius: 8px; font-size: 0.92rem; display: none; }
.feedback.error { display: block; background: #FBEAE9; color: var(--bad); border: 1px solid #EBC4C1; }
.feedback.success { display: block; background: #E8F3EC; color: var(--ok); border: 1px solid #BFDEC9; }
.feedback a { color: inherit; font-weight: 650; }
.feedback .mono { font-family: var(--mono); }
footer { margin-top: 20px; font-size: 0.8rem; color: var(--ink-soft); }
</style>
</head>
<body>
<main>
<header>
<h1>Service-Ticket anlegen</h1>
<p>Zuerst den Kunden waehlen, dann das Anliegen erfassen. Das Ticket landet direkt in Redmine.</p>
</header>
<section class="step" id="stepCustomer">
<div class="step-label"><span class="num">1</span> Kunde</div>
<div class="search-wrap" id="searchWrap">
<label for="customerSearch">Kundennummer oder Firmenname</label>
<input type="text" id="customerSearch" autocomplete="off" role="combobox"
aria-expanded="false" aria-controls="suggestions"
placeholder="z. B. K-10042 oder Mueller">
<div class="suggestions" id="suggestions" role="listbox"></div>
</div>
<div class="customer-chip" id="customerChip">
<span class="cnum" id="chipNumber"></span>
<span class="cname" id="chipName"></span>
<button type="button" id="chipReset">Aendern</button>
</div>
</section>
<section class="step" id="stepIssue" data-disabled="true">
<div class="step-label"><span class="num">2</span> Anliegen</div>
<label for="subject">Betreff</label>
<input type="text" id="subject" maxlength="200" placeholder="Kurze Zusammenfassung der Stoerung">
<label for="description">Beschreibung</label>
<textarea id="description" placeholder="Was ist passiert? Fehlermeldungen, betroffene Systeme, Ansprechpartner ..."></textarea>
<div class="actions">
<button type="button" class="submit" id="submitButton" disabled>Ticket anlegen</button>
</div>
<div class="feedback" id="feedback"></div>
</section>
<footer>Der Kundenbezug wird serverseitig gegen die Stammdaten geprueft, bevor das Ticket erstellt wird.</footer>
</main>
<script>
(function () {
"use strict";
const searchInput = document.getElementById("customerSearch");
const searchWrap = document.getElementById("searchWrap");
const suggestionsBox = document.getElementById("suggestions");
const customerChip = document.getElementById("customerChip");
const chipNumber = document.getElementById("chipNumber");
const chipName = document.getElementById("chipName");
const chipReset = document.getElementById("chipReset");
const stepIssue = document.getElementById("stepIssue");
const subjectInput = document.getElementById("subject");
const descriptionInput = document.getElementById("description");
const submitButton = document.getElementById("submitButton");
const feedback = document.getElementById("feedback");
let selectedCustomer = null;
let currentResults = [];
let activeIndex = -1;
let debounceTimer = null;
function updateSubmitState() {
const subjectOk = subjectInput.value.trim().length >= 5;
submitButton.disabled = !(selectedCustomer && subjectOk);
}
function closeSuggestions() {
suggestionsBox.classList.remove("open");
searchInput.setAttribute("aria-expanded", "false");
activeIndex = -1;
}
function renderSuggestions() {
suggestionsBox.innerHTML = "";
if (currentResults.length === 0) {
const empty = document.createElement("div");
empty.className = "suggestion-empty";
empty.textContent = "Kein Kunde gefunden. Nummer und Schreibweise pruefen.";
suggestionsBox.appendChild(empty);
} else {
currentResults.forEach((customer, index) => {
const item = document.createElement("button");
item.type = "button";
item.className = index === activeIndex ? "suggestion active" : "suggestion";
item.setAttribute("role", "option");
item.innerHTML = "";
const numberSpan = document.createElement("span");
numberSpan.className = "cnum";
numberSpan.textContent = customer.number;
const nameSpan = document.createElement("span");
nameSpan.textContent = customer.name;
item.appendChild(numberSpan);
item.appendChild(nameSpan);
item.addEventListener("click", () => selectCustomer(customer));
suggestionsBox.appendChild(item);
});
}
suggestionsBox.classList.add("open");
searchInput.setAttribute("aria-expanded", "true");
}
async function runSearch(query) {
try {
const response = await fetch("/api/customers?search=" + encodeURIComponent(query));
const payload = await response.json();
currentResults = payload.results;
activeIndex = currentResults.length > 0 ? 0 : -1;
renderSuggestions();
} catch (error) {
currentResults = [];
renderSuggestions();
}
}
function selectCustomer(customer) {
selectedCustomer = customer;
chipNumber.textContent = customer.number;
chipName.textContent = customer.name;
customerChip.classList.add("visible");
searchWrap.style.display = "none";
closeSuggestions();
stepIssue.dataset.disabled = "false";
updateSubmitState();
subjectInput.focus();
}
function resetCustomer() {
selectedCustomer = null;
customerChip.classList.remove("visible");
searchWrap.style.display = "";
searchInput.value = "";
stepIssue.dataset.disabled = "true";
updateSubmitState();
searchInput.focus();
}
searchInput.addEventListener("input", () => {
const query = searchInput.value.trim();
clearTimeout(debounceTimer);
if (query.length < 2) { closeSuggestions(); return; }
debounceTimer = setTimeout(() => runSearch(query), 200);
});
searchInput.addEventListener("keydown", (event) => {
const isOpen = suggestionsBox.classList.contains("open");
if (event.key === "ArrowDown" && isOpen) {
event.preventDefault();
activeIndex = Math.min(activeIndex + 1, currentResults.length - 1);
renderSuggestions();
} else if (event.key === "ArrowUp" && isOpen) {
event.preventDefault();
activeIndex = Math.max(activeIndex - 1, 0);
renderSuggestions();
} else if (event.key === "Enter" && isOpen && activeIndex >= 0) {
event.preventDefault();
selectCustomer(currentResults[activeIndex]);
} else if (event.key === "Escape") {
closeSuggestions();
}
});
document.addEventListener("click", (event) => {
if (!searchWrap.contains(event.target)) { closeSuggestions(); }
});
chipReset.addEventListener("click", resetCustomer);
subjectInput.addEventListener("input", updateSubmitState);
submitButton.addEventListener("click", async () => {
feedback.className = "feedback";
submitButton.disabled = true;
submitButton.textContent = "Wird angelegt ...";
try {
const response = await fetch("/api/tickets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
customerNumber: selectedCustomer.number,
subject: subjectInput.value,
description: descriptionInput.value,
}),
});
const payload = await response.json();
if (response.status === 201) {
const dryRunHint = payload.dryRun ? " (Trockenlauf, nicht wirklich in Redmine angelegt)" : "";
feedback.className = "feedback success";
feedback.innerHTML = "Ticket <a class=\"mono\" href=\"" + payload.issueUrl + "\" target=\"_blank\" rel=\"noopener\">#" + payload.issueId + "</a> fuer <span class=\"mono\">" + payload.customer.number + "</span> angelegt." + dryRunHint;
subjectInput.value = "";
descriptionInput.value = "";
} else {
feedback.className = "feedback error";
feedback.textContent = payload.error || "Unbekannter Fehler beim Anlegen des Tickets.";
}
} catch (error) {
feedback.className = "feedback error";
feedback.textContent = "Server nicht erreichbar. Bitte spaeter erneut versuchen.";
} finally {
submitButton.textContent = "Ticket anlegen";
updateSubmitState();
}
});
})();
</script>
</body>
</html>
+154
View File
@@ -0,0 +1,154 @@
import express from "express";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// --- Konfiguration (per Umgebungsvariablen, siehe .env.example) ---
const config = {
port: Number(process.env.PORT) || 3000,
redmineUrl: (process.env.REDMINE_URL || "https://redmine.unicon-gmbh.de/redmine").replace(/\/+$/, ""),
redmineApiKey: process.env.REDMINE_API_KEY || "",
projectId: Number(process.env.REDMINE_PROJECT_ID) || 1,
trackerId: Number(process.env.REDMINE_TRACKER_ID) || 3,
customerFieldId: Number(process.env.CUSTOMER_FIELD_ID) || 5,
customersFile: process.env.CUSTOMERS_FILE || path.join(__dirname, "data", "customers.csv"),
};
// Ohne API-Key laeuft der Server im Trockenlauf: Tickets werden nur simuliert.
const dryRun = config.redmineApiKey === "";
// --- Kundenstammdaten laden (CSV: Kundennummer;Firmenname) ---
function loadCustomers(filePath) {
const rawContent = fs.readFileSync(filePath, "utf8");
return rawContent
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line !== "" && !line.startsWith("#"))
.map((line) => {
const [number, ...nameParts] = line.split(";");
return { number: number.trim(), name: nameParts.join(";").trim() };
})
.filter((customer) => customer.number !== "" && customer.name !== "");
}
let customers = [];
try {
customers = loadCustomers(config.customersFile);
console.log(`${customers.length} Kunden aus ${config.customersFile} geladen`);
} catch (error) {
console.error(`Kundendatei konnte nicht gelesen werden: ${error.message}`);
process.exit(1);
}
const customersByNumber = new Map(customers.map((customer) => [customer.number, customer]));
// --- Suche: Treffer am Wortanfang zuerst, dann Teilstring-Treffer ---
function searchCustomers(query, limit) {
const needle = query.toLocaleLowerCase("de-DE");
const startMatches = [];
const partialMatches = [];
for (const customer of customers) {
const haystack = `${customer.number} ${customer.name}`.toLocaleLowerCase("de-DE");
if (!haystack.includes(needle)) continue;
const isStartMatch = haystack.startsWith(needle) || haystack.includes(` ${needle}`);
if (isStartMatch) { startMatches.push(customer); } else { partialMatches.push(customer); }
if (startMatches.length >= limit) break;
}
return startMatches.concat(partialMatches).slice(0, limit);
}
// --- Redmine-Anbindung ---
async function createRedmineIssue(payload) {
if (dryRun) {
return { id: Math.floor(Math.random() * 9000) + 1000, dryRun: true };
}
const response = await fetch(`${config.redmineUrl}/issues.json`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Redmine-API-Key": config.redmineApiKey,
},
body: JSON.stringify({ issue: payload }),
});
if (response.status === 201) {
const body = await response.json();
return { id: body.issue.id, dryRun: false };
}
let details = `HTTP ${response.status}`;
try {
const errorBody = await response.json();
if (Array.isArray(errorBody.errors)) { details = errorBody.errors.join(", "); }
} catch { /* Antwort war kein JSON, Status reicht als Info */ }
const redmineError = new Error(details);
redmineError.statusCode = response.status;
throw redmineError;
}
// --- HTTP-Server ---
const app = express();
app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));
// GET /api/customers?search=... -> Trefferliste fuer das Autocomplete
app.get("/api/customers", (request, response) => {
const query = String(request.query.search || "").trim();
if (query.length < 2) {
return response.json({ results: [], total: customers.length });
}
const results = searchCustomers(query, 12);
return response.json({ results, total: customers.length });
});
// POST /api/tickets -> validiert den Kundenbezug und legt das Ticket in Redmine an
app.post("/api/tickets", async (request, response) => {
const { customerNumber, subject, description } = request.body || {};
const customer = customersByNumber.get(String(customerNumber || "").trim());
if (!customer) {
return response.status(422).json({ error: "Unbekannte Kundennummer. Bitte einen Kunden aus der Vorschlagsliste waehlen." });
}
if (!subject || String(subject).trim().length < 5) {
return response.status(422).json({ error: "Der Betreff muss mindestens 5 Zeichen lang sein." });
}
const issuePayload = {
project_id: config.projectId,
tracker_id: config.trackerId,
subject: String(subject).trim(),
description: String(description || "").trim(),
custom_fields: [{ id: config.customerFieldId, value: customer.number }],
};
try {
const created = await createRedmineIssue(issuePayload);
return response.status(201).json({
issueId: created.id,
issueUrl: `${config.redmineUrl}/issues/${created.id}`,
customer,
dryRun: created.dryRun,
});
} catch (error) {
const statusCode = error.statusCode === 422 ? 422 : 502;
console.error(`Ticket-Anlage fehlgeschlagen: ${error.message}`);
return response.status(statusCode).json({ error: `Redmine hat das Ticket abgelehnt: ${error.message}` });
}
});
// Zentraler Fehler-Handler, damit keine Stacktraces an den Client gehen
app.use((error, request, response, next) => {
console.error(error);
response.status(500).json({ error: "Interner Fehler. Details stehen im Server-Log." });
});
app.listen(config.port, () => {
const mode = dryRun ? "TROCKENLAUF (kein REDMINE_API_KEY gesetzt, Tickets werden simuliert)" : `verbunden mit ${config.redmineUrl}`;
console.log(`Ticket-Portal laeuft auf http://localhost:${config.port} - Modus: ${mode}`);
});