Compare commits

...

9 Commits

Author SHA1 Message Date
Falko 746686b2f4 Kunden ID entfernt 2026-07-20 13:54:04 +02:00
Falko 6cd737d988 log entfernt 2026-07-20 09:18:02 +02:00
Falko fcf16bb91a footer entfernt 2026-07-20 08:28:49 +02:00
Falko 5989046b53 Erweiterte Werte übernehmen 2026-07-17 12:44:13 +02:00
Falko 5b20729877 Daten via SQL (pw muss in .env eingetragen werden), Erweitert-Modus um nach anderen Feldern zu suchen 2026-07-17 11:54:58 +02:00
Falko b89dd1be80 Script in eigene File verschoben, Startscripte geändert 2026-07-17 09:01:15 +02:00
Falko a4d857fcec ä,ö,ü 2026-07-15 08:57:41 +02:00
Falko 6a4c576bde fixed Pfad für styles.css 2026-07-14 07:46:21 +02:00
Falko 95b2f252d4 styles in Datei verschoben 2026-07-13 14:29:02 +02:00
10 changed files with 1648 additions and 324 deletions
+9 -3
View File
@@ -1,9 +1,15 @@
# 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 PORT=3000
REDMINE_URL=https://redmine.unicon-gmbh.de/redmine REDMINE_URL=https://redmine.unicon-gmbh.de/redmine
REDMINE_API_KEY= REDMINE_API_KEY=
REDMINE_PROJECT_ID=1 REDMINE_PROJECT_ID=1
REDMINE_TRACKER_ID=3 REDMINE_TRACKER_ID=3
CUSTOMER_FIELD_ID=5 CUSTOMER_FIELD_ID=5
CUSTOMERS_FILE=./data/customers.csv #CUSTOMERS_REFRESH_MS=300000
# WAWIS-SQL-Server (jdbc:jtds:sqlserver://192.168.1.16:1433;databaseName=wawis)
# Nur lesender Zugriff (SELECT auf kunde/kunzu) - Kundenstammdaten für die Ticket-Anlage
DB_SERVER=192.168.1.16
DB_PORT=1433
DB_DATABASE=wawis
DB_USER=report
#DB_PASSWORD=
+2 -8
View File
@@ -1,4 +1,5 @@
node_modules node_modules
data
# docs # docs
.vscode .vscode
@@ -11,11 +12,4 @@ node_modules
*.7z *.7z
Thumbs.db Thumbs.db
.DS_Store .DS_Store
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
+23 -5
View File
@@ -2,9 +2,10 @@
Vorgeschaltete Webseite für die Ticket-Anlage mit verpflichtendem Kundenbezug Vorgeschaltete Webseite für die Ticket-Anlage mit verpflichtendem Kundenbezug
(Lösung #1 aus [redmine.unicon-gmbh.de](https://redmine.unicon-gmbh.de/redmine/issues/6)). (Lösung #1 aus [redmine.unicon-gmbh.de](https://redmine.unicon-gmbh.de/redmine/issues/6)).
Die Kundenauswahl läuft über ein Autocomplete gegen die eigenen Stammdaten. Die Kundenauswahl läuft über ein Autocomplete gegen die Kundenstammdaten aus
Die Kunden müssen also nicht als Auswahlliste in Redmine gepflegt werden. dem WAWIS-SQL-Server (nur lesender Zugriff). Die Kunden müssen also nicht als
Die Kundennummer wird in ein Text-Custom-Field des Tickets geschrieben. Auswahlliste in Redmine gepflegt werden. Die Kundennummer wird in ein
Text-Custom-Field des Tickets geschrieben.
## Ablauf ## Ablauf
@@ -37,13 +38,30 @@ Danach http://localhost:3000 öffnen.
| REDMINE_PROJECT_ID | Ziel-Projekt für neue Tickets | | REDMINE_PROJECT_ID | Ziel-Projekt für neue Tickets |
| REDMINE_TRACKER_ID | Tracker (z. B. Support) | | REDMINE_TRACKER_ID | Tracker (z. B. Support) |
| CUSTOMER_FIELD_ID | ID des Custom Fields für die Kundennummer | | CUSTOMER_FIELD_ID | ID des Custom Fields für die Kundennummer |
| CUSTOMERS_FILE | CSV mit den Kundenstammdaten (Nummer;Name) | | CUSTOMERS_REFRESH_MS | Aktualisierungsintervall der Kundenliste in ms (Standard 300000 = 5 min) |
| DB_SERVER | Host des WAWIS-SQL-Servers |
| DB_PORT | Port des SQL-Servers (Standard 1433) |
| DB_DATABASE | Datenbankname (wawis) |
| DB_USER | SQL-User (report) |
| DB_PASSWORD | Passwort des SQL-Users |
Die Feld-ID lässt sich per GET /custom_fields.json ermitteln. Die Feld-ID lässt sich per GET /custom_fields.json ermitteln.
## SQL-Server-Anbindung (WAWIS)
Die Kundenstammdaten (Nummer + Name) werden beim Start und danach alle
`CUSTOMERS_REFRESH_MS` im Hintergrund aus dem WAWIS-SQL-Server geladen
(`db.js`, Standard-Abfrage auf `kunde`/`kunzu`). Es wird ausschließlich
gelesen (SELECT) es gibt keinen Schreibzugriff auf die Datenbank.
Gesperrte oder inaktive Kunden (`bsper`/`baktiv`) werden für die Auswahl
ausgeblendet. Schlägt eine Aktualisierung fehl, bleibt die zuletzt geladene
Liste aktiv.
Zugangsdaten stehen in `.env` (`DB_SERVER`, `DB_PORT`, `DB_DATABASE`, `DB_USER`,
`DB_PASSWORD`).
## Nächste Schritte Richtung Produktion ## Nächste Schritte Richtung Produktion
- Kundenliste direkt aus CRM/ERP ziehen (DB-View oder Export per Cron)
- Authentifizierung für die Mitarbeiter (z. B. LDAP/SSO im Reverse Proxy) - Authentifizierung für die Mitarbeiter (z. B. LDAP/SSO im Reverse Proxy)
- WS-Nummer als zweites Custom Field automatisch mitschreiben - WS-Nummer als zweites Custom Field automatisch mitschreiben
- HTTPS und Betrieb hinter dem vorhandenen Reverse Proxy - HTTPS und Betrieb hinter dem vorhandenen Reverse Proxy
+49
View File
@@ -0,0 +1,49 @@
import sql from "mssql";
// --- SQL-Server-Anbindung (WAWIS), siehe .env.example ---
const sqlConfig = {
server: process.env.DB_SERVER || "192.168.1.16",
port: Number(process.env.DB_PORT) || 1433,
database: process.env.DB_DATABASE || "wawis",
user: process.env.DB_USER || "report",
password: process.env.DB_PASSWORD || "",
options: {
encrypt: false,
trustServerCertificate: true,
},
};
let poolPromise;
function getPool() {
if (!poolPromise) {
poolPromise = new sql.ConnectionPool(sqlConfig).connect().catch((error) => {
poolPromise = undefined;
throw error;
});
}
return poolPromise;
}
// Nur lesender Zugriff (SELECT) - der SQL-Server wird von diesem Portal nie beschrieben.
// gesperrt/inaktiv kommt aus T kunzu als Text ('ja'/'nein')
const CUSTOMERS_QUERY = `
SELECT
kunde.debez
, kunde.debnr
, kunde.kg_id as kg_id
, kunde.kugru as kugru
, sydat as sydat
, datlv as datlv
, fibuk as fibuk
, RTRIM(bsper) as bsper
, RTRIM(baktiv) as baktiv
FROM kunde
INNER JOIN kunzu ON kunzu.ku_id = kunde.ku_id
`;
export async function fetchCustomersFromSql() {
const pool = await getPool();
const result = await pool.request().query(CUSTOMERS_QUERY);
return result.recordset;
}
+902 -1
View File
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -5,11 +5,17 @@
"type": "module", "type": "module",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"dev": "node --env-file=.env server.js & sleep 1 && xdg-open http://localhost:3000",
"dev:dry": "node server.js & sleep 1 && xdg-open http://localhost:3000",
"start": "node --env-file=.env server.js", "start": "node --env-file=.env server.js",
"start:dry": "node server.js" "prod": "NODE_ENV=production node --env-file=.env server.js",
"stop": "pkill -f 'node --env-file=.env server.js' || true"
},
"engines": {
"node": ">=20"
}, },
"engines": { "node": ">=20" },
"dependencies": { "dependencies": {
"express": "^4.19.0" "express": "^4.19.0",
"mssql": "^12.7.0"
} }
} }
+24 -275
View File
@@ -4,115 +4,13 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ticket anlegen - Service-Portal</title> <title>Ticket anlegen - Service-Portal</title>
<style> <link href="styles.css" rel="stylesheet">
: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> </head>
<body> <body>
<main> <main>
<header> <header>
<h1>Service-Ticket anlegen</h1> <h1>Service-Ticket anlegen</h1>
<p>Zuerst den Kunden waehlen, dann das Anliegen erfassen. Das Ticket landet direkt in Redmine.</p> <p>Zuerst den Kunden wählen, dann das Anliegen erfassen. Das Ticket landet direkt in Redmine.</p>
</header> </header>
<section class="step" id="stepCustomer"> <section class="step" id="stepCustomer">
@@ -122,14 +20,32 @@
<label for="customerSearch">Kundennummer oder Firmenname</label> <label for="customerSearch">Kundennummer oder Firmenname</label>
<input type="text" id="customerSearch" autocomplete="off" role="combobox" <input type="text" id="customerSearch" autocomplete="off" role="combobox"
aria-expanded="false" aria-controls="suggestions" aria-expanded="false" aria-controls="suggestions"
placeholder="z. B. K-10042 oder Mueller"> placeholder="z. B. K-10042 oder Müller">
<div class="suggestions" id="suggestions" role="listbox"></div> <div class="suggestions" id="suggestions" role="listbox"></div>
<label class="checkbox-row">
<input type="checkbox" id="advancedToggle">
Erweitert
</label>
<div id="advancedFields" hidden>
<div class="field-group" role="group" aria-label="Erweiterte Suchfelder">
<button type="button" class="field-toggle" data-field="kugru" aria-pressed="false">Kundengruppe</button>
<button type="button" class="field-toggle" data-field="kg_id" aria-pressed="false">Kundengruppen-ID</button>
<button type="button" class="field-toggle" data-field="sydat" aria-pressed="false">Erstellt am</button>
<button type="button" class="field-toggle" data-field="datlv" aria-pressed="false">Letzte Änderung</button>
<button type="button" class="field-toggle" data-field="fibuk" aria-pressed="false">Fibu-Konto</button>
</div>
<label class="checkbox-row">
<input type="checkbox" id="applyExtraValues">
Erweiterte Werte übernehmen
</label>
</div>
</div> </div>
<div class="customer-chip" id="customerChip"> <div class="customer-chip" id="customerChip">
<span class="cnum" id="chipNumber"></span> <span class="cnum" id="chipNumber"></span>
<span class="cname" id="chipName"></span> <span class="cname" id="chipName"></span>
<button type="button" id="chipReset">Aendern</button> <button type="button" id="chipReset">Ändern</button>
</div> </div>
</section> </section>
@@ -137,7 +53,7 @@
<div class="step-label"><span class="num">2</span> Anliegen</div> <div class="step-label"><span class="num">2</span> Anliegen</div>
<label for="subject">Betreff</label> <label for="subject">Betreff</label>
<input type="text" id="subject" maxlength="200" placeholder="Kurze Zusammenfassung der Stoerung"> <input type="text" id="subject" maxlength="200" placeholder="Kurze Zusammenfassung">
<label for="description">Beschreibung</label> <label for="description">Beschreibung</label>
<textarea id="description" placeholder="Was ist passiert? Fehlermeldungen, betroffene Systeme, Ansprechpartner ..."></textarea> <textarea id="description" placeholder="Was ist passiert? Fehlermeldungen, betroffene Systeme, Ansprechpartner ..."></textarea>
@@ -147,175 +63,8 @@
</div> </div>
<div class="feedback" id="feedback"></div> <div class="feedback" id="feedback"></div>
</section> </section>
<footer>Der Kundenbezug wird serverseitig gegen die Stammdaten geprueft, bevor das Ticket erstellt wird.</footer>
</main> </main>
<script> <script src="index.js"></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> </body>
</html> </html>
+232
View File
@@ -0,0 +1,232 @@
(function () {
"use strict";
const searchInput = document.getElementById("customerSearch");
const searchWrap = document.getElementById("searchWrap");
const suggestionsBox = document.getElementById("suggestions");
const advancedToggle = document.getElementById("advancedToggle");
const advancedFields = document.getElementById("advancedFields");
const fieldToggleButtons = Array.from(document.querySelectorAll(".field-toggle"));
const applyExtraValuesToggle = document.getElementById("applyExtraValues");
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 currentQuery = "";
let activeIndex = -1;
let debounceTimer = null;
const selectedFields = new Set();
function appendHighlighted(target, text, query) {
const words = (query || "").toLowerCase().split(/\s+/).filter(Boolean);
if (words.length === 0) { target.appendChild(document.createTextNode(text)); return; }
const lowerText = text.toLowerCase();
const mask = new Array(text.length).fill(false);
for (const word of words) {
let fromIndex = 0;
let matchIndex;
while ((matchIndex = lowerText.indexOf(word, fromIndex)) !== -1) {
for (let i = matchIndex; i < matchIndex + word.length; i++) { mask[i] = true; }
fromIndex = matchIndex + 1;
}
}
let i = 0;
while (i < text.length) {
const highlighted = mask[i];
let j = i;
while (j < text.length && mask[j] === highlighted) { j++; }
const chunk = text.slice(i, j);
if (highlighted) {
const strong = document.createElement("strong");
strong.textContent = chunk;
target.appendChild(strong);
} else {
target.appendChild(document.createTextNode(chunk));
}
i = j;
}
}
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 prüfen.";
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";
appendHighlighted(numberSpan, customer.number, currentQuery);
const nameSpan = document.createElement("span");
appendHighlighted(nameSpan, customer.name, currentQuery);
if (customer.extraMatches && customer.extraMatches.length > 0) {
nameSpan.appendChild(document.createTextNode(` (${customer.extraMatches.join(", ")})`));
}
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) {
currentQuery = query;
try {
const params = new URLSearchParams({ search: query });
if (advancedToggle.checked && selectedFields.size > 0) {
params.set("fields", Array.from(selectedFields).join(","));
}
const response = await fetch("/api/customers?" + params.toString());
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;
const useExtraValues = applyExtraValuesToggle.checked && customer.extraMatches && customer.extraMatches.length > 0;
chipName.textContent = useExtraValues
? `${customer.name} (${customer.extraMatches.join(", ")})`
: 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(); }
});
advancedToggle.addEventListener("change", () => {
advancedFields.hidden = !advancedToggle.checked;
if (!advancedToggle.checked) {
selectedFields.clear();
fieldToggleButtons.forEach((button) => button.setAttribute("aria-pressed", "false"));
}
if (currentQuery.length >= 2) { runSearch(currentQuery); }
});
fieldToggleButtons.forEach((button) => {
button.addEventListener("click", () => {
const field = button.dataset.field;
const isPressed = button.getAttribute("aria-pressed") === "true";
button.setAttribute("aria-pressed", String(!isPressed));
if (isPressed) { selectedFields.delete(field); } else { selectedFields.add(field); }
if (currentQuery.length >= 2) { runSearch(currentQuery); }
});
});
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 später erneut versuchen.";
} finally {
submitButton.textContent = "Ticket anlegen";
updateSubmitState();
}
});
})();
+325
View File
@@ -0,0 +1,325 @@
: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;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
margin: 14px 0 0;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.checkbox-row input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
.field-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.field-group[hidden] {
display: none;
}
.field-toggle {
font: inherit;
font-size: 0.85rem;
padding: 6px 12px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
color: var(--ink-soft);
cursor: pointer;
}
.field-toggle:hover {
border-color: var(--accent);
}
.field-toggle[aria-pressed="true"] {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
/* 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);
}
+73 -29
View File
@@ -1,7 +1,7 @@
import express from "express"; import express from "express";
import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { fetchCustomersFromSql } from "./db.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -13,48 +13,83 @@ const config = {
projectId: Number(process.env.REDMINE_PROJECT_ID) || 1, projectId: Number(process.env.REDMINE_PROJECT_ID) || 1,
trackerId: Number(process.env.REDMINE_TRACKER_ID) || 3, trackerId: Number(process.env.REDMINE_TRACKER_ID) || 3,
customerFieldId: Number(process.env.CUSTOMER_FIELD_ID) || 5, customerFieldId: Number(process.env.CUSTOMER_FIELD_ID) || 5,
customersFile: process.env.CUSTOMERS_FILE || path.join(__dirname, "data", "customers.csv"), customersRefreshMs: Number(process.env.CUSTOMERS_REFRESH_MS) || 5 * 60 * 1000,
}; };
// Ohne API-Key laeuft der Server im Trockenlauf: Tickets werden nur simuliert. // Ohne API-Key läuft der Server im Trockenlauf: Tickets werden nur simuliert.
const dryRun = config.redmineApiKey === ""; const dryRun = config.redmineApiKey === "";
// --- Kundenstammdaten laden (CSV: Kundennummer;Firmenname) --- // --- Kundenstammdaten aus dem WAWIS-SQL-Server (nur lesend, siehe db.js) ---
function loadCustomers(filePath) { // gesperrte/inaktive Kunden werden für die Ticket-Anlage ausgeblendet
const rawContent = fs.readFileSync(filePath, "utf8"); let customers = [];
return rawContent let customersByNumber = new Map();
.split(/\r?\n/)
.map((line) => line.trim()) // Felder, die zusätzlich zu Nummer/Name über die erweiterte Suche durchsucht werden können
.filter((line) => line !== "" && !line.startsWith("#")) const ADVANCED_SEARCH_FIELDS = ["kugru", "kg_id", "sydat", "datlv", "fibuk"];
.map((line) => {
const [number, ...nameParts] = line.split(";"); function applyCustomers(rows) {
return { number: number.trim(), name: nameParts.join(";").trim() }; customers = rows
}) .filter((row) => row.baktiv === "ja" && row.bsper !== "ja")
.map((row) => ({
number: String(row.debnr).trim(),
name: String(row.debez || "").trim(),
kugru: row.kugru,
kg_id: row.kg_id,
sydat: row.sydat,
datlv: row.datlv,
fibuk: row.fibuk,
}))
.filter((customer) => customer.number !== "" && customer.name !== ""); .filter((customer) => customer.number !== "" && customer.name !== "");
customersByNumber = new Map(customers.map((customer) => [customer.number, customer]));
}
async function refreshCustomers() {
const rows = await fetchCustomersFromSql();
applyCustomers(rows);
console.log(`${customers.length} aktive Kunden aus dem SQL-Server geladen`);
} }
let customers = [];
try { try {
customers = loadCustomers(config.customersFile); await refreshCustomers();
console.log(`${customers.length} Kunden aus ${config.customersFile} geladen`);
} catch (error) { } catch (error) {
console.error(`Kundendatei konnte nicht gelesen werden: ${error.message}`); console.error(`Kundendaten konnten nicht vom SQL-Server geladen werden: ${error.message}`);
process.exit(1); process.exit(1);
} }
const customersByNumber = new Map(customers.map((customer) => [customer.number, customer])); // Periodische Aktualisierung im Hintergrund; bei Fehler bleiben die alten Daten aktiv.
setInterval(() => {
refreshCustomers().catch((error) => {
console.error(`Aktualisierung der Kundendaten fehlgeschlagen, alte Daten bleiben aktiv: ${error.message}`);
});
}, config.customersRefreshMs);
// --- Suche: Treffer am Wortanfang zuerst, dann Teilstring-Treffer --- // --- Suche: Treffer am Wortanfang zuerst, dann Teilstring-Treffer ---
function searchCustomers(query, limit) { // extraFields (aus ADVANCED_SEARCH_FIELDS) erweitert die durchsuchten Werte um Felder aus der Abfrage
const needle = query.toLocaleLowerCase("de-DE"); // Bei mehreren durch Leerzeichen getrennten Wörtern müssen alle Wörter im Treffer vorkommen (UND-Verknüpfung)
function searchCustomers(query, extraFields, limit) {
const words = query.toLocaleLowerCase("de-DE").split(/\s+/).filter(Boolean);
const startMatches = []; const startMatches = [];
const partialMatches = []; const partialMatches = [];
for (const customer of customers) { for (const customer of customers) {
const haystack = `${customer.number} ${customer.name}`.toLocaleLowerCase("de-DE"); const haystackParts = [customer.number, customer.name];
if (!haystack.includes(needle)) continue; for (const field of extraFields) {
const isStartMatch = haystack.startsWith(needle) || haystack.includes(` ${needle}`); if (customer[field] !== undefined && customer[field] !== null) {
if (isStartMatch) { startMatches.push(customer); } else { partialMatches.push(customer); } haystackParts.push(String(customer[field]));
}
}
const haystack = haystackParts.join(" ").toLocaleLowerCase("de-DE");
if (!words.every((word) => haystack.includes(word))) continue;
// Werte aller ausgewählten erweiterten Felder, unabhängig davon, welches Wort den Treffer ausgelöst hat
const extraMatches = extraFields
.map((field) => customer[field])
.filter((value) => value !== undefined && value !== null && String(value).trim() !== "")
.map((value) => String(value));
const match = { number: customer.number, name: customer.name, extraMatches };
const isStartMatch = words.every((word) => haystack.startsWith(word) || haystack.includes(` ${word}`));
if (isStartMatch) { startMatches.push(match); } else { partialMatches.push(match); }
if (startMatches.length >= limit) break; if (startMatches.length >= limit) break;
} }
@@ -97,13 +132,22 @@ const app = express();
app.use(express.json()); app.use(express.json());
app.use(express.static(path.join(__dirname, "public"))); app.use(express.static(path.join(__dirname, "public")));
// GET /api/customers?search=... -> Trefferliste fuer das Autocomplete // GET /api/customers?search=... -> Trefferliste für das Autocomplete
app.get("/api/customers", (request, response) => { app.get("/api/customers", (request, response) => {
const query = String(request.query.search || "").trim(); const query = String(request.query.search || "").trim();
if (query.length < 2) { if (query.length < 2) {
return response.json({ results: [], total: customers.length }); return response.json({ results: [], total: customers.length });
} }
const results = searchCustomers(query, 12); const extraFields = String(request.query.fields || "")
.split(",")
.map((field) => field.trim())
.filter((field) => ADVANCED_SEARCH_FIELDS.includes(field));
const matches = searchCustomers(query, extraFields, 12);
const results = matches.map((customer) => ({
number: customer.number,
name: customer.name,
extraMatches: customer.extraMatches,
}));
return response.json({ results, total: customers.length }); return response.json({ results, total: customers.length });
}); });
@@ -113,7 +157,7 @@ app.post("/api/tickets", async (request, response) => {
const customer = customersByNumber.get(String(customerNumber || "").trim()); const customer = customersByNumber.get(String(customerNumber || "").trim());
if (!customer) { if (!customer) {
return response.status(422).json({ error: "Unbekannte Kundennummer. Bitte einen Kunden aus der Vorschlagsliste waehlen." }); return response.status(422).json({ error: "Unbekannte Kundennummer. Bitte einen Kunden aus der Vorschlagsliste wählen." });
} }
if (!subject || String(subject).trim().length < 5) { if (!subject || String(subject).trim().length < 5) {
return response.status(422).json({ error: "Der Betreff muss mindestens 5 Zeichen lang sein." }); return response.status(422).json({ error: "Der Betreff muss mindestens 5 Zeichen lang sein." });
@@ -150,5 +194,5 @@ app.use((error, request, response, next) => {
app.listen(config.port, () => { app.listen(config.port, () => {
const mode = dryRun ? "TROCKENLAUF (kein REDMINE_API_KEY gesetzt, Tickets werden simuliert)" : `verbunden mit ${config.redmineUrl}`; 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}`); console.log(`Ticket-Portal läuft auf http://localhost:${config.port} - Modus: ${mode}`);
}); });