fix(multi): FDISK sync, order UX, Ausbildungen display, untracked items

FDISK sync:
- fix(sync): strip 'KFZ-Führerschein / ' prefix from license class select option text before whitelist validation
- fix(sync): fix navigateAndGetTableRows to pick date column with most matches (prevents sidebar tables from hijacking dateColIdx for Beförderungen)
- fix(sync): input.value fallback now falls through to textContent when value is empty
- feat(sync): expand Ausbildungen to capture Kursnummer, Kurz, Kurs (full name), Erfolgscode from FDISK table; add migration 086

External orders (Bestellungen):
- fix(bestellungen): allow erhalten_menge editing in lieferung_pruefen status (resolves deadlock preventing order completion)
- fix(bestellungen): show cost/received warnings for bestellt/teillieferung/lieferung_pruefen, not just when abgeschlossen is next
- feat(bestellungen): rename status labels to Eingereicht, Genehmigt, Teilweise geliefert, Vollständig geliefert
- fix(bestellungen): remove duplicate Bestelldatum from PDF export
- feat(bestellungen): add catalog item autocomplete to creation form (auto-fills bezeichnung + artikelnummer)

Internal orders (Ausruestungsanfrage):
- fix(ausruestung): untracked items with zuweisung_typ='keine' now appear in Nicht-zugewiesen tab (frontend filter was too strict)
- feat(ausruestung): load user-specific personal items when ordering for another user
- feat(ausruestung): auto-set ist_ersatz=true for items from personal equipment list; add toggle for catalog/free-text items
- feat(ausruestung): load item eigenschaften when personal item with artikel_id is checked

Ausbildungen display:
- feat(mitglieder): show kursname (full), kurs_kurzbezeichnung chip, erfolgscode chip (color-coded) per Ausbildung entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-04-15 13:22:04 +02:00
parent 916aa488d2
commit 50dbf6e9fd
14 changed files with 182 additions and 65 deletions

View File

@@ -778,6 +778,9 @@ async function scrapeAusbildungenFromDetailPage(
const results: Array<{
standesbuchNr: string;
kursname: string | null;
kursnummer: string | null;
kurzbezeichnung: string | null;
erfolgscode: string | null;
kursDatum: string | null;
ablaufdatum: string | null;
ort: string | null;
@@ -818,11 +821,14 @@ async function scrapeAusbildungenFromDetailPage(
// Try to find column indices from headers
const hdr = bestHeaders.map(h => h.toLowerCase());
let kursnameIdx = hdr.findIndex(h => h.includes('kurs') || h.includes('ausbildung') || h.includes('bezeichnung'));
let datumIdx = hdr.findIndex(h => h.includes('datum') || h.includes('abschluss'));
let ablaufIdx = hdr.findIndex(h => h.includes('ablauf') || h.includes('gültig'));
let ortIdx = hdr.findIndex(h => h.includes('ort'));
let bemIdx = hdr.findIndex(h => h.includes('bem') || h.includes('info'));
let kursnummerIdx = hdr.findIndex(h => h.includes('nummer'));
let kurzIdx = hdr.findIndex(h => h === 'kurz' || (h.includes('kurz') && !h.includes('name')));
let kursnameIdx = hdr.findIndex(h => h === 'kurs' || h.includes('ausbildung') || h.includes('bezeichnung'));
let datumIdx = hdr.findIndex(h => h.includes('datum') || h.includes('abschluss'));
let erfolgscodeIdx = hdr.findIndex(h => h.includes('erfolg') || h.includes('code'));
let ablaufIdx = hdr.findIndex(h => h.includes('ablauf') || h.includes('gültig'));
let ortIdx = hdr.findIndex(h => h.includes('ort'));
let bemIdx = hdr.findIndex(h => h.includes('bem') || h.includes('info'));
// If headers didn't help, scan data for date-like columns and text columns
if (kursnameIdx === -1 && bestRows.length > 0) {
@@ -866,11 +872,14 @@ async function scrapeAusbildungenFromDetailPage(
// parseDate is not available inside evaluate; return raw values
results.push({
standesbuchNr: stNr,
kursnummer: (kursnummerIdx >= 0 ? row.cells[kursnummerIdx] : null)?.trim() || null,
kurzbezeichnung: (kurzIdx >= 0 ? row.cells[kurzIdx] : null)?.trim() || null,
kursname,
kursDatum: rawDatum || null,
ablaufdatum: rawAblauf || null,
ort: rawOrt,
bemerkung: rawBem,
erfolgscode: (erfolgscodeIdx >= 0 ? row.cells[erfolgscodeIdx] : null)?.trim() || null,
syncKey: `${stNr}::${kursname}::${rawDatum ?? ''}`,
});
}
@@ -884,6 +893,9 @@ async function scrapeAusbildungenFromDetailPage(
return {
standesbuchNr: a.standesbuchNr,
kursname: a.kursname as string,
kursnummer: a.kursnummer,
kurzbezeichnung: a.kurzbezeichnung,
erfolgscode: a.erfolgscode,
kursDatum,
ablaufdatum: parseDate(a.ablaufdatum),
ort: a.ort,
@@ -948,7 +960,7 @@ async function navigateAndGetTableRows(
tableClass: cls,
cells: tds.map(td => {
const input = td.querySelector('input[type="text"], input:not([type])') as HTMLInputElement | null;
if (input) return input.value?.trim() ?? '';
if (input && input.value?.trim()) return input.value.trim();
const sel = td.querySelector('select') as HTMLSelectElement | null;
if (sel) {
const opt = sel.options[sel.selectedIndex];
@@ -975,17 +987,25 @@ async function navigateAndGetTableRows(
cells: r.cells.map(c => c.replace(/\u00A0/g, ' ').trim()),
}));
// Find date column dynamically: look for a DD.MM.YYYY pattern in any column
// Find date column dynamically: count date matches per column across ALL rows
// and pick the column with the MOST matches (avoids picking stray date in nav tables)
const datePattern = /^\d{2}\.\d{2}\.\d{4}$/;
let dateColIdx = -1;
const dateCountByCol: Record<number, number> = {};
for (const r of mapped) {
for (let ci = 0; ci < r.cells.length; ci++) {
if (datePattern.test(r.cells[ci] ?? '')) {
dateColIdx = ci;
break;
dateCountByCol[ci] = (dateCountByCol[ci] || 0) + 1;
}
}
if (dateColIdx >= 0) break;
}
let dateColIdx = -1;
let maxCount = 0;
for (const [col, count] of Object.entries(dateCountByCol)) {
const colNum = Number(col);
if (count > maxCount || (count === maxCount && (dateColIdx === -1 || colNum < dateColIdx))) {
dateColIdx = colNum;
maxCount = count;
}
}
const dataRows = dateColIdx >= 0
@@ -1296,8 +1316,10 @@ async function scrapeMemberFahrgenehmigungen(
]);
const results: FdiskFahrgenehmigung[] = [];
for (const row of rawRows) {
const klasse = cellText(row.klasse);
let klasse = cellText(row.klasse);
if (!klasse) continue;
// FDISK select option text includes prefix "KFZ-Führerschein / B" — extract just the class code
if (klasse.includes(' / ')) klasse = klasse.split(' / ').pop()!.trim();
// Validate klasse against whitelist — skip non-class data
if (!VALID_LICENSE_CLASSES.has(klasse.toUpperCase())) {
log(` → Skipping invalid klasse: "${klasse}"`);