fix(mitglieder): improve Fahrgenehmigungen labels, pagination, and AT20 sync
This commit is contained in:
@@ -20,6 +20,7 @@ import { requirePermission } from '../middleware/rbac.middleware';
|
|||||||
import { auditExport } from '../middleware/audit.middleware';
|
import { auditExport } from '../middleware/audit.middleware';
|
||||||
import auditService, { AuditAction, AuditResourceType, AuditFilters } from '../services/audit.service';
|
import auditService, { AuditAction, AuditResourceType, AuditFilters } from '../services/audit.service';
|
||||||
import cleanupService from '../services/cleanup.service';
|
import cleanupService from '../services/cleanup.service';
|
||||||
|
import atemschutzService from '../services/atemschutz.service';
|
||||||
import userService from '../services/user.service';
|
import userService from '../services/user.service';
|
||||||
import pool from '../config/database';
|
import pool from '../config/database';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
@@ -207,6 +208,10 @@ router.post(
|
|||||||
try {
|
try {
|
||||||
const response = await axios.post(`${FDISK_SYNC_URL}/trigger`, req.body, { timeout: 5000 });
|
const response = await axios.post(`${FDISK_SYNC_URL}/trigger`, req.body, { timeout: 5000 });
|
||||||
res.json({ success: true, data: response.data });
|
res.json({ success: true, data: response.data });
|
||||||
|
// Fire-and-forget: sync AT20 courses to atemschutz_traeger after FDISK data is written
|
||||||
|
atemschutzService.syncLehrgangFromKurse().catch(err =>
|
||||||
|
logger.error('AT20 Atemschutz-Sync fehlgeschlagen', { error: err })
|
||||||
|
);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
||||||
res.status(409).json({ success: false, message: 'Sync already in progress' });
|
res.status(409).json({ success: false, message: 'Sync already in progress' });
|
||||||
|
|||||||
@@ -216,6 +216,40 @@ class AtemschutzService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// SYNC LEHRGANG FROM FDISK COURSES (AT20)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans the ausbildung table for AT20 courses with erfolgscode = 'mit Erfolg'
|
||||||
|
* and upserts atemschutz_traeger records accordingly:
|
||||||
|
* - No record: creates one with atemschutz_lehrgang=true + lehrgang_datum
|
||||||
|
* - Record exists, lehrgang false: sets lehrgang=true + date (if unset)
|
||||||
|
* - Record exists, lehrgang already true: no-op (preserves manual data)
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
FROM ausbildung a
|
||||||
|
WHERE a.kurs_kurzbezeichnung = 'AT20'
|
||||||
|
AND a.erfolgscode = 'mit Erfolg'
|
||||||
|
GROUP BY a.user_id
|
||||||
|
ON CONFLICT (user_id) DO UPDATE
|
||||||
|
SET atemschutz_lehrgang = true,
|
||||||
|
lehrgang_datum = COALESCE(atemschutz_traeger.lehrgang_datum, EXCLUDED.lehrgang_datum),
|
||||||
|
updated_at = NOW()
|
||||||
|
`);
|
||||||
|
const processed = result.rowCount ?? 0;
|
||||||
|
logger.info('AT20 Atemschutz-Sync abgeschlossen', { processed });
|
||||||
|
return { processed };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AtemschutzService.syncLehrgangFromKurse fehlgeschlagen', { error });
|
||||||
|
throw new Error('AT20 Atemschutz-Sync fehlgeschlagen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// EXPIRING CERTIFICATIONS
|
// EXPIRING CERTIFICATIONS
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ export default function BuchhaltungTransaktionForm() {
|
|||||||
<Select
|
<Select
|
||||||
value={form.konto_id ?? ''}
|
value={form.konto_id ?? ''}
|
||||||
label="Konto (Topf)"
|
label="Konto (Topf)"
|
||||||
onChange={e => setForm(f => ({ ...f, konto_id: e.target.value ? Number(e.target.value) : null, ausgaben_typ: null }))}
|
onChange={e => setForm(f => ({ ...f, konto_id: e.target.value ? Number(e.target.value) : null }))}
|
||||||
>
|
>
|
||||||
<MenuItem value=""><em>Kein Konto</em></MenuItem>
|
<MenuItem value=""><em>Kein Konto</em></MenuItem>
|
||||||
{konten.map(k => (
|
{konten.map(k => (
|
||||||
|
|||||||
@@ -1265,20 +1265,22 @@ function MitgliedDetail() {
|
|||||||
<Typography variant="body2" fontWeight={500}>
|
<Typography variant="body2" fontWeight={500}>
|
||||||
{f.klasse}
|
{f.klasse}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
{f.ausstellungsdatum ? new Date(f.ausstellungsdatum).toLocaleDateString('de-AT') : '—'}
|
<Typography variant="caption" color="text.secondary">
|
||||||
</Typography>
|
Erhalten am: {f.ausstellungsdatum ? new Date(f.ausstellungsdatum).toLocaleDateString('de-AT') : '—'}
|
||||||
|
</Typography>
|
||||||
|
{f.gueltig_bis && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Gültig bis: {new Date(f.gueltig_bis).toLocaleDateString('de-AT')}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{(f.behoerde || f.nummer) && (
|
{(f.behoerde || f.nummer) && (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
{[f.behoerde, f.nummer].filter(Boolean).join(' · ')}
|
{[f.behoerde, f.nummer].filter(Boolean).join(' · ')}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{f.gueltig_bis && (
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Gültig bis: {new Date(f.gueltig_bis).toLocaleDateString('de-AT')}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -120,25 +120,16 @@ function Mitglieder() {
|
|||||||
}, [debouncedSearch, selectedStatus, selectedDienstgrad, page, pageSize]);
|
}, [debouncedSearch, selectedStatus, selectedDienstgrad, page, pageSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset to page 0 when search changes
|
|
||||||
if (debouncedSearch !== prevSearch.current) {
|
if (debouncedSearch !== prevSearch.current) {
|
||||||
prevSearch.current = debouncedSearch;
|
prevSearch.current = debouncedSearch;
|
||||||
setPage(0);
|
if (page !== 0) {
|
||||||
return;
|
setPage(0);
|
||||||
|
return; // page change will re-render → fetchMembers recreated → effect re-fires
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fetchMembers();
|
fetchMembers();
|
||||||
}, [fetchMembers, debouncedSearch]);
|
}, [fetchMembers, debouncedSearch]);
|
||||||
|
|
||||||
// Also fetch when page/filters change (skip initial mount to avoid double-fetch)
|
|
||||||
const isInitialMount = useRef(true);
|
|
||||||
useEffect(() => {
|
|
||||||
if (isInitialMount.current) {
|
|
||||||
isInitialMount.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fetchMembers();
|
|
||||||
}, [page, pageSize, selectedStatus, selectedDienstgrad]);
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
// Event handlers
|
// Event handlers
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user