change dat format in member overview, sync exams to atemschutz tool, rework member detail page

This commit is contained in:
Matthias Hochmeister
2026-04-20 10:32:20 +02:00
parent d5291360c9
commit 752dfe474c
16 changed files with 874 additions and 182 deletions

View File

@@ -171,9 +171,9 @@ class AtemschutzController {
user_id: item.user_id,
typ: 'atemschutz_expiry',
titel: item.untersuchung_status === 'abgelaufen'
? 'G26 Untersuchung abgelaufen'
: 'G26 Untersuchung läuft bald ab',
nachricht: `Ihre G26 Untersuchung ${item.untersuchung_status === 'abgelaufen' ? 'ist abgelaufen' : 'läuft bald ab'}.`,
? 'Atemschutztauglichkeitsuntersuchung abgelaufen'
: 'Atemschutztauglichkeitsuntersuchung läuft bald ab',
nachricht: `Ihre Atemschutztauglichkeitsuntersuchung ${item.untersuchung_status === 'abgelaufen' ? 'ist abgelaufen' : 'läuft bald ab'}.`,
schwere: item.untersuchung_status === 'abgelaufen' ? 'fehler' : 'warnung',
quell_typ: 'atemschutz_untersuchung',
quell_id: item.id,

View File

@@ -45,6 +45,8 @@ class MemberController {
search,
page,
pageSize,
sortBy,
sortDir,
} = req.query as Record<string, string | undefined>;
// Arrays can be sent as ?status[]=aktiv&status[]=jugend or CSV
@@ -62,6 +64,8 @@ class MemberController {
dienstgrad: normalizeArray(dienstgradParam) as any,
page: page ? parseInt(page, 10) || 1 : 1,
pageSize: pageSize ? (parseInt(pageSize, 10) === 0 ? 0 : Math.min(parseInt(pageSize, 10) || 25, 100)) : 25,
sortBy,
sortDir: sortDir === 'desc' ? 'desc' : sortDir === 'asc' ? 'asc' : undefined,
});
res.status(200).json({

View File

@@ -15,6 +15,7 @@ export interface AtemschutzTraeger {
id: string;
user_id: string;
atemschutz_lehrgang: boolean;
lehrgang_theorie_only: boolean;
lehrgang_datum: Date | null;
untersuchung_datum: Date | null;
untersuchung_gueltig_bis: Date | null;

View File

@@ -166,6 +166,8 @@ export interface MemberFilters {
dienstgrad?: DienstgradEnum[];
page?: number; // 1-based
pageSize?: number;
sortBy?: string;
sortDir?: 'asc' | 'desc';
}
// ============================================================

View File

@@ -229,19 +229,46 @@ class AtemschutzService {
*/
async syncLehrgangFromKurse(): Promise<{ processed: number }> {
try {
const result = await pool.query(`
INSERT INTO atemschutz_traeger (id, user_id, atemschutz_lehrgang, lehrgang_datum)
SELECT uuid_generate_v4(), a.user_id, true, MIN(a.kurs_datum)
// Pass 1: Full lehrgang — AT20/AGL with 'mit Erfolg' or 'mit ausgezeichnetem Erfolg'
const fullResult = await pool.query(`
INSERT INTO atemschutz_traeger (id, user_id, atemschutz_lehrgang, lehrgang_datum, lehrgang_theorie_only)
SELECT uuid_generate_v4(), a.user_id, true, MIN(a.kurs_datum), false
FROM ausbildung a
WHERE a.kurs_kurzbezeichnung = 'AT20'
AND a.erfolgscode = 'mit Erfolg'
WHERE (
(TRIM(a.kurs_kurzbezeichnung) = 'AT20' AND TRIM(a.erfolgscode) IN ('mit Erfolg', 'mit ausgezeichnetem Erfolg'))
OR
(TRIM(a.kurs_kurzbezeichnung) = 'AGL' AND TRIM(a.erfolgscode) IN ('mit Erfolg', 'mit ausgezeichnetem Erfolg'))
)
GROUP BY a.user_id
ON CONFLICT (user_id) DO UPDATE
SET atemschutz_lehrgang = true,
lehrgang_theorie_only = false,
lehrgang_datum = COALESCE(atemschutz_traeger.lehrgang_datum, EXCLUDED.lehrgang_datum),
updated_at = NOW()
`);
const processed = result.rowCount ?? 0;
// Pass 2: Theory-only — AT20 with 'mit Erfolg Theorie', only for users without a full lehrgang
const theorieResult = await pool.query(`
INSERT INTO atemschutz_traeger (id, user_id, atemschutz_lehrgang, lehrgang_datum, lehrgang_theorie_only)
SELECT uuid_generate_v4(), a.user_id, true, MIN(a.kurs_datum), true
FROM ausbildung a
WHERE TRIM(a.kurs_kurzbezeichnung) = 'AT20'
AND TRIM(a.erfolgscode) = 'mit Erfolg Theorie'
AND NOT EXISTS (
SELECT 1 FROM atemschutz_traeger at2
WHERE at2.user_id = a.user_id
AND at2.atemschutz_lehrgang = true
AND at2.lehrgang_theorie_only = false
)
GROUP BY a.user_id
ON CONFLICT (user_id) DO UPDATE
SET atemschutz_lehrgang = true,
lehrgang_theorie_only = true,
lehrgang_datum = COALESCE(atemschutz_traeger.lehrgang_datum, EXCLUDED.lehrgang_datum),
updated_at = NOW()
`);
const processed = (fullResult.rowCount ?? 0) + (theorieResult.rowCount ?? 0);
logger.info('AT20 Atemschutz-Sync abgeschlossen', { processed });
return { processed };
} catch (error) {

View File

@@ -133,6 +133,8 @@ class MemberService {
dienstgrad,
page = 1,
pageSize = 25,
sortBy,
sortDir,
} = filters ?? {};
const conditions: string[] = ['u.is_active = TRUE'];
@@ -165,6 +167,20 @@ class MemberService {
const whereClause = `WHERE ${conditions.join(' AND ')}`;
const fetchAll = pageSize === 0;
// Build ORDER BY — whitelist columns to prevent SQL injection
const SORT_COLUMNS: Record<string, string> = {
family_name: 'u.family_name',
eintrittsdatum: 'mp.eintrittsdatum',
dienstgrad: 'mp.dienstgrad',
status: 'mp.status',
fdisk_standesbuch_nr: 'mp.fdisk_standesbuch_nr',
};
const defaultOrder = 'u.family_name ASC NULLS LAST, u.given_name ASC NULLS LAST';
const dir = sortDir === 'desc' ? 'DESC' : 'ASC';
const orderBy = sortBy && SORT_COLUMNS[sortBy]
? `${SORT_COLUMNS[sortBy]} ${dir} NULLS LAST, u.family_name ASC NULLS LAST`
: defaultOrder;
let dataQuery: string;
if (fetchAll) {
dataQuery = `
@@ -186,7 +202,7 @@ class MemberService {
FROM users u
LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id
${whereClause}
ORDER BY u.family_name ASC NULLS LAST, u.given_name ASC NULLS LAST
ORDER BY ${orderBy}
`;
} else {
const offset = (page - 1) * pageSize;
@@ -209,7 +225,7 @@ class MemberService {
FROM users u
LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id
${whereClause}
ORDER BY u.family_name ASC NULLS LAST, u.given_name ASC NULLS LAST
ORDER BY ${orderBy}
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
`;
values.push(pageSize, offset);