update
This commit is contained in:
@@ -271,6 +271,20 @@ class MemberController {
|
|||||||
res.status(500).json({ success: false, message: 'Fehler beim Laden der Fahrgenehmigungen.' });
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Fahrgenehmigungen.' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/members/:userId/ausbildungen
|
||||||
|
*/
|
||||||
|
async getAusbildungen(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params as Record<string, string>;
|
||||||
|
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();
|
export default new MemberController();
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ router.get(
|
|||||||
memberController.getFahrgenehmigungen.bind(memberController)
|
memberController.getFahrgenehmigungen.bind(memberController)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:userId/ausbildungen',
|
||||||
|
requirePermission('members:read'),
|
||||||
|
memberController.getAusbildungen.bind(memberController)
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline middleware for PATCH /:userId.
|
* Inline middleware for PATCH /:userId.
|
||||||
* Enforces that the caller is either the profile owner OR holds members:write.
|
* Enforces that the caller is either the profile owner OR holds members:write.
|
||||||
|
|||||||
@@ -683,6 +683,25 @@ class MemberService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all Ausbildungen (training courses) for a given user from the FDISK-synced table.
|
||||||
|
*/
|
||||||
|
async getAusbildungen(userId: string): Promise<any[]> {
|
||||||
|
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();
|
export default new MemberService();
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
HighlightOff as HighlightOffIcon,
|
HighlightOff as HighlightOffIcon,
|
||||||
MilitaryTech as MilitaryTechIcon,
|
MilitaryTech as MilitaryTechIcon,
|
||||||
LocalHospital as LocalHospitalIcon,
|
LocalHospital as LocalHospitalIcon,
|
||||||
|
School as SchoolIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
@@ -58,7 +59,7 @@ import {
|
|||||||
formatPhone,
|
formatPhone,
|
||||||
UpdateMemberProfileData,
|
UpdateMemberProfileData,
|
||||||
} from '../types/member.types';
|
} 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 type { AtemschutzUebersicht } from '../types/atemschutz.types';
|
||||||
import { UntersuchungErgebnisLabel } from '../types/atemschutz.types';
|
import { UntersuchungErgebnisLabel } from '../types/atemschutz.types';
|
||||||
|
|
||||||
@@ -244,6 +245,7 @@ function MitgliedDetail() {
|
|||||||
const [befoerderungen, setBefoerderungen] = useState<Befoerderung[]>([]);
|
const [befoerderungen, setBefoerderungen] = useState<Befoerderung[]>([]);
|
||||||
const [untersuchungen, setUntersuchungen] = useState<Untersuchung[]>([]);
|
const [untersuchungen, setUntersuchungen] = useState<Untersuchung[]>([]);
|
||||||
const [fahrgenehmigungen, setFahrgenehmigungen] = useState<Fahrgenehmigung[]>([]);
|
const [fahrgenehmigungen, setFahrgenehmigungen] = useState<Fahrgenehmigung[]>([]);
|
||||||
|
const [ausbildungen, setAusbildungen] = useState<Ausbildung[]>([]);
|
||||||
|
|
||||||
// Edit form state — only the fields the user is allowed to change
|
// Edit form state — only the fields the user is allowed to change
|
||||||
const [formData, setFormData] = useState<UpdateMemberProfileData>({});
|
const [formData, setFormData] = useState<UpdateMemberProfileData>({});
|
||||||
@@ -285,6 +287,7 @@ function MitgliedDetail() {
|
|||||||
membersService.getBefoerderungen(userId).then(setBefoerderungen).catch(() => setBefoerderungen([]));
|
membersService.getBefoerderungen(userId).then(setBefoerderungen).catch(() => setBefoerderungen([]));
|
||||||
membersService.getUntersuchungen(userId).then(setUntersuchungen).catch(() => setUntersuchungen([]));
|
membersService.getUntersuchungen(userId).then(setUntersuchungen).catch(() => setUntersuchungen([]));
|
||||||
membersService.getFahrgenehmigungen(userId).then(setFahrgenehmigungen).catch(() => setFahrgenehmigungen([]));
|
membersService.getFahrgenehmigungen(userId).then(setFahrgenehmigungen).catch(() => setFahrgenehmigungen([]));
|
||||||
|
membersService.getAusbildungen(userId).then(setAusbildungen).catch(() => setAusbildungen([]));
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
// Populate form from current profile
|
// Populate form from current profile
|
||||||
@@ -1065,6 +1068,54 @@ function MitgliedDetail() {
|
|||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Ausbildungen */}
|
||||||
|
{ausbildungen.length > 0 && (
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
avatar={<SchoolIcon color="primary" />}
|
||||||
|
title="Ausbildungen"
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
{ausbildungen.map((a) => (
|
||||||
|
<Box key={a.id} sx={{ py: 0.75, borderBottom: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||||
|
<Typography variant="body2" fontWeight={500}>
|
||||||
|
{a.kursname}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{a.kurs_datum ? new Date(a.kurs_datum).toLocaleDateString('de-AT') : '—'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{(a.ort || a.status !== 'abgeschlossen') && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 0.25 }}>
|
||||||
|
{a.ort && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{a.ort}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{a.status !== 'abgeschlossen' && (
|
||||||
|
<Chip
|
||||||
|
label={a.status === 'in_bearbeitung' ? 'In Bearbeitung' : 'Abgelaufen'}
|
||||||
|
size="small"
|
||||||
|
color={a.status === 'abgelaufen' ? 'warning' : 'info'}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{a.ablaufdatum && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Gültig bis: {new Date(a.ablaufdatum).toLocaleDateString('de-AT')}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Untersuchungen */}
|
{/* Untersuchungen */}
|
||||||
{untersuchungen.length > 0 && (
|
{untersuchungen.length > 0 && (
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Befoerderung,
|
Befoerderung,
|
||||||
Untersuchung,
|
Untersuchung,
|
||||||
Fahrgenehmigung,
|
Fahrgenehmigung,
|
||||||
|
Ausbildung,
|
||||||
} from '../types/member.types';
|
} from '../types/member.types';
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
@@ -143,4 +144,9 @@ export const membersService = {
|
|||||||
const response = await api.get<ApiItemResponse<Fahrgenehmigung[]>>(`/api/members/${userId}/fahrgenehmigungen`);
|
const response = await api.get<ApiItemResponse<Fahrgenehmigung[]>>(`/api/members/${userId}/fahrgenehmigungen`);
|
||||||
return response.data?.data ?? [];
|
return response.data?.data ?? [];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getAusbildungen(userId: string): Promise<Ausbildung[]> {
|
||||||
|
const response = await api.get<ApiItemResponse<Ausbildung[]>>(`/api/members/${userId}/ausbildungen`);
|
||||||
|
return response.data?.data ?? [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -228,3 +228,14 @@ export interface Fahrgenehmigung {
|
|||||||
klasse: string;
|
klasse: string;
|
||||||
created_at: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,123 @@ function cellText(text: string | undefined | null): string | null {
|
|||||||
return t || 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<string>,
|
||||||
|
knownNames: Set<string>,
|
||||||
|
): Promise<FdiskMember[]> {
|
||||||
|
type ParsedRow = Awaited<ReturnType<typeof parseRowsFromTable>>[number];
|
||||||
|
|
||||||
|
const seenStNrs = new Set<string>();
|
||||||
|
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<string>, knownNames: Set<string>): Promise<{
|
export async function scrapeAll(username: string, password: string, knownStNrs: Set<string>, knownNames: Set<string>): Promise<{
|
||||||
members: FdiskMember[];
|
members: FdiskMember[];
|
||||||
ausbildungen: FdiskAusbildung[];
|
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.
|
// Navigate via the menu frame (left.aspx) to set session state properly.
|
||||||
const mainFrame = await navigateToMemberList(page);
|
const mainFrame = await navigateToMemberList(page);
|
||||||
|
|
||||||
const members = await scrapeMembers(mainFrame);
|
const members = await scrapeKnownMembers(mainFrame, knownStNrs, knownNames);
|
||||||
log(`Found ${members.length} members`);
|
log(`Found ${members.length} members (targeted query)`);
|
||||||
|
|
||||||
const ausbildungen: FdiskAusbildung[] = [];
|
const ausbildungen: FdiskAusbildung[] = [];
|
||||||
const befoerderungen: FdiskBefoerderung[] = [];
|
const befoerderungen: FdiskBefoerderung[] = [];
|
||||||
@@ -73,13 +190,6 @@ export async function scrapeAll(username: string, password: string, knownStNrs:
|
|||||||
const fahrgenehmigungen: FdiskFahrgenehmigung[] = [];
|
const fahrgenehmigungen: FdiskFahrgenehmigung[] = [];
|
||||||
|
|
||||||
for (const member of members) {
|
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 {
|
try {
|
||||||
// Navigate to member detail page — use direct URL if available, else search+click fallback
|
// Navigate to member detail page — use direct URL if available, else search+click fallback
|
||||||
const onDetail = member.detailUrl
|
const onDetail = member.detailUrl
|
||||||
|
|||||||
Reference in New Issue
Block a user