diff --git a/backend/src/controllers/member.controller.ts b/backend/src/controllers/member.controller.ts index bbfb42c..533ecbb 100644 --- a/backend/src/controllers/member.controller.ts +++ b/backend/src/controllers/member.controller.ts @@ -271,6 +271,20 @@ class MemberController { res.status(500).json({ success: false, message: 'Fehler beim Laden der Fahrgenehmigungen.' }); } } + + /** + * GET /api/members/:userId/ausbildungen + */ + async getAusbildungen(req: Request, res: Response): Promise { + try { + const { userId } = req.params as Record; + const data = await memberService.getAusbildungen(userId); + res.status(200).json({ success: true, data }); + } catch (error) { + logger.error('getAusbildungen error', { error, userId: req.params.userId }); + res.status(500).json({ success: false, message: 'Fehler beim Laden der Ausbildungen.' }); + } + } } export default new MemberController(); diff --git a/backend/src/routes/member.routes.ts b/backend/src/routes/member.routes.ts index 6b0a6fc..90510d8 100644 --- a/backend/src/routes/member.routes.ts +++ b/backend/src/routes/member.routes.ts @@ -57,6 +57,12 @@ router.get( memberController.getFahrgenehmigungen.bind(memberController) ); +router.get( + '/:userId/ausbildungen', + requirePermission('members:read'), + memberController.getAusbildungen.bind(memberController) +); + /** * Inline middleware for PATCH /:userId. * Enforces that the caller is either the profile owner OR holds members:write. diff --git a/backend/src/services/member.service.ts b/backend/src/services/member.service.ts index 2f4483d..2f482d7 100644 --- a/backend/src/services/member.service.ts +++ b/backend/src/services/member.service.ts @@ -683,6 +683,25 @@ class MemberService { return []; } } + + /** + * Returns all Ausbildungen (training courses) for a given user from the FDISK-synced table. + */ + async getAusbildungen(userId: string): Promise { + try { + const result = await pool.query( + `SELECT id, kursname, kurs_datum, ablaufdatum, ort, bemerkung, status, created_at + FROM ausbildung + WHERE user_id = $1 + ORDER BY kurs_datum DESC NULLS LAST, created_at DESC`, + [userId] + ); + return result.rows; + } catch (error) { + logger.error('Error fetching Ausbildungen', { error, userId }); + return []; + } + } } export default new MemberService(); diff --git a/frontend/src/pages/MitgliedDetail.tsx b/frontend/src/pages/MitgliedDetail.tsx index ba4ad94..4c651e2 100644 --- a/frontend/src/pages/MitgliedDetail.tsx +++ b/frontend/src/pages/MitgliedDetail.tsx @@ -35,6 +35,7 @@ import { HighlightOff as HighlightOffIcon, MilitaryTech as MilitaryTechIcon, LocalHospital as LocalHospitalIcon, + School as SchoolIcon, } from '@mui/icons-material'; import { useParams, useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; @@ -58,7 +59,7 @@ import { formatPhone, UpdateMemberProfileData, } from '../types/member.types'; -import type { Befoerderung, Untersuchung, Fahrgenehmigung } from '../types/member.types'; +import type { Befoerderung, Untersuchung, Fahrgenehmigung, Ausbildung } from '../types/member.types'; import type { AtemschutzUebersicht } from '../types/atemschutz.types'; import { UntersuchungErgebnisLabel } from '../types/atemschutz.types'; @@ -244,6 +245,7 @@ function MitgliedDetail() { const [befoerderungen, setBefoerderungen] = useState([]); const [untersuchungen, setUntersuchungen] = useState([]); const [fahrgenehmigungen, setFahrgenehmigungen] = useState([]); + const [ausbildungen, setAusbildungen] = useState([]); // Edit form state — only the fields the user is allowed to change const [formData, setFormData] = useState({}); @@ -285,6 +287,7 @@ function MitgliedDetail() { membersService.getBefoerderungen(userId).then(setBefoerderungen).catch(() => setBefoerderungen([])); membersService.getUntersuchungen(userId).then(setUntersuchungen).catch(() => setUntersuchungen([])); membersService.getFahrgenehmigungen(userId).then(setFahrgenehmigungen).catch(() => setFahrgenehmigungen([])); + membersService.getAusbildungen(userId).then(setAusbildungen).catch(() => setAusbildungen([])); }, [userId]); // Populate form from current profile @@ -1065,6 +1068,54 @@ function MitgliedDetail() { )} + {/* Ausbildungen */} + {ausbildungen.length > 0 && ( + + + } + title="Ausbildungen" + /> + + {ausbildungen.map((a) => ( + + + + {a.kursname} + + + {a.kurs_datum ? new Date(a.kurs_datum).toLocaleDateString('de-AT') : '—'} + + + {(a.ort || a.status !== 'abgeschlossen') && ( + + {a.ort && ( + + {a.ort} + + )} + {a.status !== 'abgeschlossen' && ( + + )} + + )} + {a.ablaufdatum && ( + + Gültig bis: {new Date(a.ablaufdatum).toLocaleDateString('de-AT')} + + )} + + ))} + + + + )} + {/* Untersuchungen */} {untersuchungen.length > 0 && ( diff --git a/frontend/src/services/members.ts b/frontend/src/services/members.ts index 0a6724f..b364dcd 100644 --- a/frontend/src/services/members.ts +++ b/frontend/src/services/members.ts @@ -9,6 +9,7 @@ import { Befoerderung, Untersuchung, Fahrgenehmigung, + Ausbildung, } from '../types/member.types'; // ---------------------------------------------------------------- @@ -143,4 +144,9 @@ export const membersService = { const response = await api.get>(`/api/members/${userId}/fahrgenehmigungen`); return response.data?.data ?? []; }, + + async getAusbildungen(userId: string): Promise { + const response = await api.get>(`/api/members/${userId}/ausbildungen`); + return response.data?.data ?? []; + }, }; diff --git a/frontend/src/types/member.types.ts b/frontend/src/types/member.types.ts index d19f6db..6af7473 100644 --- a/frontend/src/types/member.types.ts +++ b/frontend/src/types/member.types.ts @@ -228,3 +228,14 @@ export interface Fahrgenehmigung { klasse: string; created_at: string; } + +export interface Ausbildung { + id: string; + kursname: string; + kurs_datum: string | null; + ablaufdatum: string | null; + ort: string | null; + bemerkung: string | null; + status: string; + created_at: string; +} diff --git a/sync/src/scraper.ts b/sync/src/scraper.ts index 8a6ab4e..b21a4db 100644 --- a/sync/src/scraper.ts +++ b/sync/src/scraper.ts @@ -39,6 +39,123 @@ function cellText(text: string | undefined | null): string | null { return t || null; } +/** + * Fetch only members we care about, rather than scraping the full member list. + * + * Phase 1: one search per known StNr (exact match). + * Phase 2: if knownNames is non-empty, a single unfiltered fetch (page 1 only) + * to pick up members matched by name (first-time linking). + * + * Returns deduplicated FdiskMember[]. + */ +async function scrapeKnownMembers( + frame: Frame, + knownStNrs: Set, + knownNames: Set, +): Promise { + type ParsedRow = Awaited>[number]; + + const seenStNrs = new Set(); + const allRows: ParsedRow[] = []; + + // --- Phase 1: fetch by exact StNr --- + log(`scrapeKnownMembers: fetching ${knownStNrs.size} known StNrs`); + for (const stNr of knownStNrs) { + const formOk = await frame.evaluate((sn) => { + const form = (document as any).forms['frmsearch']; + if (!form) return false; + const fromFld = form.elements['ListFilter$searchstandesbuchnummer'] as HTMLInputElement | null; + const toFld = form.elements['ListFilter$searchstandesbuchnummer_bis'] as HTMLInputElement | null; + if (!fromFld || !toFld) return false; + fromFld.value = sn; + toFld.value = sn; + return true; + }, stNr); + + if (!formOk) { + log(` WARN: search form not usable for StNr ${stNr}`); + continue; + } + + await Promise.all([ + frame.waitForNavigation({ waitUntil: 'networkidle', timeout: 30000 }), + frame.evaluate(() => { (document as any).forms['frmsearch'].submit(); }), + ]); + + const rows = await parseRowsFromTable(frame); + for (const r of rows) { + if (r.standesbuchNr && !seenStNrs.has(r.standesbuchNr)) { + seenStNrs.add(r.standesbuchNr); + allRows.push(r); + } + } + log(` StNr ${stNr}: ${rows.length} row(s)`); + + // Be gentle on the server + await frame.page().waitForTimeout(300); + } + + // --- Phase 2: single unfiltered fetch for name-matching --- + if (knownNames.size > 0) { + log(`scrapeKnownMembers: unfiltered fetch for ${knownNames.size} name-based matches`); + + // Clear StNr filter + await frame.evaluate(() => { + const form = (document as any).forms['frmsearch']; + if (!form) return; + const fromFld = form.elements['ListFilter$searchstandesbuchnummer'] as HTMLInputElement | null; + const toFld = form.elements['ListFilter$searchstandesbuchnummer_bis'] as HTMLInputElement | null; + if (fromFld) fromFld.value = ''; + if (toFld) toFld.value = ''; + }); + + await Promise.all([ + frame.waitForNavigation({ waitUntil: 'networkidle', timeout: 30000 }), + frame.evaluate(() => { (document as any).forms['frmsearch'].submit(); }), + ]); + + const rows = await parseRowsFromTable(frame); + let matched = 0; + for (const r of rows) { + if (!r.standesbuchNr || seenStNrs.has(r.standesbuchNr)) continue; + const nameKey = `${(r.vorname || '').toLowerCase()}::${(r.zuname || '').toLowerCase()}`; + if (knownNames.has(nameKey)) { + seenStNrs.add(r.standesbuchNr); + allRows.push(r); + matched++; + } + } + log(` Unfiltered page: ${rows.length} total rows, ${matched} name-matched`); + } + + log(`scrapeKnownMembers: ${allRows.length} members collected`); + + // Build FdiskMember objects + const members: FdiskMember[] = []; + for (const row of allRows) { + if (!row.standesbuchNr || !row.vorname || !row.zuname) continue; + const abmeldedatum = parseDate(row.abmeldedatum); + members.push({ + standesbuchNr: row.standesbuchNr, + dienstgrad: row.dienstgrad, + vorname: row.vorname, + zuname: row.zuname, + geburtsdatum: parseDate(row.geburtsdatum), + svnr: row.svnr || null, + eintrittsdatum: parseDate(row.eintrittsdatum), + abmeldedatum, + status: abmeldedatum ? 'ausgetreten' : 'aktiv', + detailUrl: row.href, + geburtsort: null, + geschlecht: null, + beruf: null, + wohnort: null, + plz: null, + }); + } + return members; +} + export async function scrapeAll(username: string, password: string, knownStNrs: Set, knownNames: Set): Promise<{ members: FdiskMember[]; ausbildungen: FdiskAusbildung[]; @@ -64,8 +181,8 @@ export async function scrapeAll(username: string, password: string, knownStNrs: // Navigate via the menu frame (left.aspx) to set session state properly. const mainFrame = await navigateToMemberList(page); - const members = await scrapeMembers(mainFrame); - log(`Found ${members.length} members`); + const members = await scrapeKnownMembers(mainFrame, knownStNrs, knownNames); + log(`Found ${members.length} members (targeted query)`); const ausbildungen: FdiskAusbildung[] = []; const befoerderungen: FdiskBefoerderung[] = []; @@ -73,13 +190,6 @@ export async function scrapeAll(username: string, password: string, knownStNrs: const fahrgenehmigungen: FdiskFahrgenehmigung[] = []; for (const member of members) { - // Only scrape detail pages for members with a dashboard account - // (matched by standesbuchNr or by name for first-time linking) - const nameKey = `${member.vorname.toLowerCase()}::${member.zuname.toLowerCase()}`; - if (!knownStNrs.has(member.standesbuchNr) && !knownNames.has(nameKey)) { - continue; - } - try { // Navigate to member detail page — use direct URL if available, else search+click fallback const onDetail = member.detailUrl