Daten via SQL (pw muss in .env eingetragen werden), Erweitert-Modus um nach anderen Feldern zu suchen

This commit is contained in:
2026-07-17 11:54:58 +02:00
parent b89dd1be80
commit 5b20729877
10 changed files with 1175 additions and 46 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=
+1 -7
View File
@@ -1,4 +1,5 @@
node_modules node_modules
data
# docs # docs
.vscode .vscode
@@ -12,10 +13,3 @@ node_modules
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
+51
View File
@@ -0,0 +1,51 @@
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
, kunde.ku_id as ku_id
, 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);
console.log(result);
return result.recordset;
}
+902 -1
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -10,8 +10,11 @@
"start": "node --env-file=.env server.js", "start": "node --env-file=.env server.js",
"prod": "NODE_ENV=production node --env-file=.env server.js" "prod": "NODE_ENV=production node --env-file=.env server.js"
}, },
"engines": { "node": ">=20" }, "engines": {
"node": ">=20"
},
"dependencies": { "dependencies": {
"express": "^4.19.0" "express": "^4.19.0",
"mssql": "^12.7.0"
} }
} }
+13
View File
@@ -22,6 +22,19 @@
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 Mueller">
<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 class="field-group" id="advancedFields" role="group" aria-label="Erweiterte Suchfelder" hidden>
<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>
<button type="button" class="field-toggle" data-field="ku_id" aria-pressed="false">Kunden-ID (intern)</button>
</div>
</div> </div>
<div class="customer-chip" id="customerChip"> <div class="customer-chip" id="customerChip">
+64 -3
View File
@@ -4,6 +4,9 @@
const searchInput = document.getElementById("customerSearch"); const searchInput = document.getElementById("customerSearch");
const searchWrap = document.getElementById("searchWrap"); const searchWrap = document.getElementById("searchWrap");
const suggestionsBox = document.getElementById("suggestions"); const suggestionsBox = document.getElementById("suggestions");
const advancedToggle = document.getElementById("advancedToggle");
const advancedFields = document.getElementById("advancedFields");
const fieldToggleButtons = Array.from(document.querySelectorAll(".field-toggle"));
const customerChip = document.getElementById("customerChip"); const customerChip = document.getElementById("customerChip");
const chipNumber = document.getElementById("chipNumber"); const chipNumber = document.getElementById("chipNumber");
const chipName = document.getElementById("chipName"); const chipName = document.getElementById("chipName");
@@ -16,8 +19,42 @@
let selectedCustomer = null; let selectedCustomer = null;
let currentResults = []; let currentResults = [];
let currentQuery = "";
let activeIndex = -1; let activeIndex = -1;
let debounceTimer = null; 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() { function updateSubmitState() {
const subjectOk = subjectInput.value.trim().length >= 5; const subjectOk = subjectInput.value.trim().length >= 5;
@@ -46,9 +83,9 @@
item.innerHTML = ""; item.innerHTML = "";
const numberSpan = document.createElement("span"); const numberSpan = document.createElement("span");
numberSpan.className = "cnum"; numberSpan.className = "cnum";
numberSpan.textContent = customer.number; appendHighlighted(numberSpan, customer.number, currentQuery);
const nameSpan = document.createElement("span"); const nameSpan = document.createElement("span");
nameSpan.textContent = customer.name; appendHighlighted(nameSpan, customer.name, currentQuery);
item.appendChild(numberSpan); item.appendChild(numberSpan);
item.appendChild(nameSpan); item.appendChild(nameSpan);
item.addEventListener("click", () => selectCustomer(customer)); item.addEventListener("click", () => selectCustomer(customer));
@@ -60,8 +97,13 @@
} }
async function runSearch(query) { async function runSearch(query) {
currentQuery = query;
try { try {
const response = await fetch("/api/customers?search=" + encodeURIComponent(query)); 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(); const payload = await response.json();
currentResults = payload.results; currentResults = payload.results;
activeIndex = currentResults.length > 0 ? 0 : -1; activeIndex = currentResults.length > 0 ? 0 : -1;
@@ -123,6 +165,25 @@
if (!searchWrap.contains(event.target)) { closeSuggestions(); } 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); chipReset.addEventListener("click", resetCustomer);
subjectInput.addEventListener("input", updateSubmitState); subjectInput.addEventListener("input", updateSubmitState);
+49
View File
@@ -174,6 +174,55 @@ textarea {
font-size: 0.9rem; 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 */ /* Gewaehlter Kunde als Chip */
.customer-chip { .customer-chip {
display: none; display: none;
+57 -24
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,47 +13,75 @@ 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 läuft 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", "ku_id"];
.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,
ku_id: row.ku_id,
}))
.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) {
haystackParts.push(String(customer[field]));
}
}
const haystack = haystackParts.join(" ").toLocaleLowerCase("de-DE");
if (!words.every((word) => haystack.includes(word))) continue;
const isStartMatch = words.every((word) => haystack.startsWith(word) || haystack.includes(` ${word}`));
if (isStartMatch) { startMatches.push(customer); } else { partialMatches.push(customer); } if (isStartMatch) { startMatches.push(customer); } else { partialMatches.push(customer); }
if (startMatches.length >= limit) break; if (startMatches.length >= limit) break;
} }
@@ -103,7 +131,12 @@ app.get("/api/customers", (request, response) => {
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 }));
return response.json({ results, total: customers.length }); return response.json({ results, total: customers.length });
}); });