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:
@@ -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}"`);
|
||||
|
||||
Reference in New Issue
Block a user