This commit is contained in:
Matthias Hochmeister
2026-03-13 14:01:06 +01:00
parent 3361f1e28d
commit 7215e7f472
10 changed files with 119 additions and 17 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS fdisk_standesbuch_nr VARCHAR(32);
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_fdisk_standesbuch_nr ON mitglieder_profile(fdisk_standesbuch_nr);

View File

@@ -64,6 +64,7 @@ export interface MitgliederProfile {
user_id: string; user_id: string;
mitglieds_nr: string | null; mitglieds_nr: string | null;
fdisk_standesbuch_nr: string | null;
dienstgrad: DienstgradEnum | null; dienstgrad: DienstgradEnum | null;
dienstgrad_seit: Date | null; dienstgrad_seit: Date | null;
funktion: FunktionEnum[]; funktion: FunktionEnum[];
@@ -172,6 +173,7 @@ export interface MemberFilters {
*/ */
export const CreateMemberProfileSchema = z.object({ export const CreateMemberProfileSchema = z.object({
mitglieds_nr: z.string().max(32).optional(), mitglieds_nr: z.string().max(32).optional(),
fdisk_standesbuch_nr: z.string().max(32).optional(),
dienstgrad: z.enum(DIENSTGRAD_VALUES).optional(), dienstgrad: z.enum(DIENSTGRAD_VALUES).optional(),
dienstgrad_seit: z.coerce.date().optional(), dienstgrad_seit: z.coerce.date().optional(),
funktion: z.array(z.enum(FUNKTION_VALUES)).default([]), funktion: z.array(z.enum(FUNKTION_VALUES)).default([]),

View File

@@ -197,13 +197,13 @@ router.post(
'/fdisk-sync/trigger', '/fdisk-sync/trigger',
authenticate, authenticate,
requirePermission('admin:access'), requirePermission('admin:access'),
async (_req: Request, res: Response): Promise<void> => { async (req: Request, res: Response): Promise<void> => {
if (!FDISK_SYNC_URL) { if (!FDISK_SYNC_URL) {
res.status(503).json({ success: false, message: 'FDISK sync service not configured' }); res.status(503).json({ success: false, message: 'FDISK sync service not configured' });
return; return;
} }
try { try {
const response = await axios.post(`${FDISK_SYNC_URL}/trigger`, {}, { 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 });
} catch (err: unknown) { } catch (err: unknown) {
if (axios.isAxiosError(err) && err.response?.status === 409) { if (axios.isAxiosError(err) && err.response?.status === 409) {

View File

@@ -37,6 +37,7 @@ class MemberService {
mp.id AS mp_id, mp.id AS mp_id,
mp.user_id AS mp_user_id, mp.user_id AS mp_user_id,
mp.mitglieds_nr AS mp_mitglieds_nr, mp.mitglieds_nr AS mp_mitglieds_nr,
mp.fdisk_standesbuch_nr AS mp_fdisk_standesbuch_nr,
mp.dienstgrad AS mp_dienstgrad, mp.dienstgrad AS mp_dienstgrad,
mp.dienstgrad_seit AS mp_dienstgrad_seit, mp.dienstgrad_seit AS mp_dienstgrad_seit,
mp.funktion AS mp_funktion, mp.funktion AS mp_funktion,
@@ -83,6 +84,7 @@ class MemberService {
id: row.mp_id, id: row.mp_id,
user_id: row.mp_user_id, user_id: row.mp_user_id,
mitglieds_nr: row.mp_mitglieds_nr, mitglieds_nr: row.mp_mitglieds_nr,
fdisk_standesbuch_nr: row.mp_fdisk_standesbuch_nr ?? null,
dienstgrad: row.mp_dienstgrad, dienstgrad: row.mp_dienstgrad,
dienstgrad_seit: row.mp_dienstgrad_seit, dienstgrad_seit: row.mp_dienstgrad_seit,
funktion: row.mp_funktion ?? [], funktion: row.mp_funktion ?? [],
@@ -283,6 +285,7 @@ class MemberService {
INSERT INTO mitglieder_profile ( INSERT INTO mitglieder_profile (
user_id, user_id,
mitglieds_nr, mitglieds_nr,
fdisk_standesbuch_nr,
dienstgrad, dienstgrad,
dienstgrad_seit, dienstgrad_seit,
funktion, funktion,
@@ -300,8 +303,8 @@ class MemberService {
bemerkungen, bemerkungen,
bild_url bild_url
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$10, $11, $12, $13, $14, $15, $16, $17, $18 $11, $12, $13, $14, $15, $16, $17, $18, $19
) )
RETURNING * RETURNING *
`; `;
@@ -309,6 +312,7 @@ class MemberService {
const values = [ const values = [
userId, userId,
data.mitglieds_nr ?? null, data.mitglieds_nr ?? null,
data.fdisk_standesbuch_nr ?? null,
data.dienstgrad ?? null, data.dienstgrad ?? null,
data.dienstgrad_seit ?? null, data.dienstgrad_seit ?? null,
data.funktion ?? [], data.funktion ?? [],
@@ -387,6 +391,7 @@ class MemberService {
const fieldMap: Record<string, any> = { const fieldMap: Record<string, any> = {
mitglieds_nr: rest.mitglieds_nr, mitglieds_nr: rest.mitglieds_nr,
fdisk_standesbuch_nr: rest.fdisk_standesbuch_nr,
funktion: rest.funktion, funktion: rest.funktion,
status: rest.status, status: rest.status,
eintrittsdatum: rest.eintrittsdatum, eintrittsdatum: rest.eintrittsdatum,

View File

@@ -1,6 +1,6 @@
import { useRef, useEffect, useCallback } from 'react'; import { useState, useRef, useEffect, useCallback } from 'react';
import { import {
Box, Button, Card, CardContent, Chip, CircularProgress, IconButton, Tooltip, Typography, Box, Button, Card, CardContent, Checkbox, Chip, CircularProgress, FormControlLabel, IconButton, Tooltip, Typography,
} from '@mui/material'; } from '@mui/material';
import SyncIcon from '@mui/icons-material/Sync'; import SyncIcon from '@mui/icons-material/Sync';
import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ContentCopyIcon from '@mui/icons-material/ContentCopy';
@@ -12,6 +12,7 @@ function FdiskSyncTab() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
const logBoxRef = useRef<HTMLDivElement>(null); const logBoxRef = useRef<HTMLDivElement>(null);
const [force, setForce] = useState(false);
const { data, isLoading, isError } = useQuery({ const { data, isLoading, isError } = useQuery({
queryKey: ['admin', 'fdisk-sync', 'logs'], queryKey: ['admin', 'fdisk-sync', 'logs'],
@@ -27,7 +28,7 @@ function FdiskSyncTab() {
}, [data?.logs.length]); }, [data?.logs.length]);
const triggerMutation = useMutation({ const triggerMutation = useMutation({
mutationFn: adminApi.fdiskSyncTrigger, mutationFn: (forceSync: boolean) => adminApi.fdiskSyncTrigger(forceSync),
onSuccess: () => { onSuccess: () => {
showSuccess('Sync gestartet'); showSuccess('Sync gestartet');
queryClient.invalidateQueries({ queryKey: ['admin', 'fdisk-sync', 'logs'] }); queryClient.invalidateQueries({ queryKey: ['admin', 'fdisk-sync', 'logs'] });
@@ -71,12 +72,16 @@ function FdiskSyncTab() {
<Button <Button
variant="contained" variant="contained"
startIcon={<SyncIcon />} startIcon={<SyncIcon />}
onClick={() => triggerMutation.mutate()} onClick={() => triggerMutation.mutate(force)}
disabled={running || triggerMutation.isPending} disabled={running || triggerMutation.isPending}
> >
Jetzt synchronisieren Jetzt synchronisieren
</Button> </Button>
</Box> </Box>
<FormControlLabel
control={<Checkbox checked={force} onChange={(e) => setForce(e.target.checked)} />}
label="Alle Mitglieder erzwungen synchronisieren"
/>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -7,6 +7,7 @@ import {
CardContent, CardContent,
CardHeader, CardHeader,
Avatar, Avatar,
Autocomplete,
Button, Button,
Chip, Chip,
Tabs, Tabs,
@@ -41,9 +42,11 @@ import {
MemberWithProfile, MemberWithProfile,
StatusEnum, StatusEnum,
DienstgradEnum, DienstgradEnum,
FunktionEnum,
TshirtGroesseEnum, TshirtGroesseEnum,
DIENSTGRAD_VALUES, DIENSTGRAD_VALUES,
STATUS_VALUES, STATUS_VALUES,
FUNKTION_VALUES,
TSHIRT_GROESSE_VALUES, TSHIRT_GROESSE_VALUES,
STATUS_LABELS, STATUS_LABELS,
STATUS_COLORS, STATUS_COLORS,
@@ -239,6 +242,7 @@ function MitgliedDetail() {
funktion: member.profile.funktion, funktion: member.profile.funktion,
status: member.profile.status, status: member.profile.status,
eintrittsdatum: toGermanDate(member.profile.eintrittsdatum) || undefined, eintrittsdatum: toGermanDate(member.profile.eintrittsdatum) || undefined,
austrittsdatum: toGermanDate(member.profile.austrittsdatum) || undefined,
geburtsdatum: toGermanDate(member.profile.geburtsdatum) || undefined, geburtsdatum: toGermanDate(member.profile.geburtsdatum) || undefined,
telefon_mobil: member.profile.telefon_mobil ?? undefined, telefon_mobil: member.profile.telefon_mobil ?? undefined,
telefon_privat: member.profile.telefon_privat ?? undefined, telefon_privat: member.profile.telefon_privat ?? undefined,
@@ -248,6 +252,7 @@ function MitgliedDetail() {
tshirt_groesse: member.profile.tshirt_groesse ?? undefined, tshirt_groesse: member.profile.tshirt_groesse ?? undefined,
schuhgroesse: member.profile.schuhgroesse ?? undefined, schuhgroesse: member.profile.schuhgroesse ?? undefined,
bemerkungen: member.profile.bemerkungen ?? undefined, bemerkungen: member.profile.bemerkungen ?? undefined,
fdisk_standesbuch_nr: member.profile.fdisk_standesbuch_nr ?? undefined,
}); });
} }
}, [member]); }, [member]);
@@ -263,6 +268,7 @@ function MitgliedDetail() {
const payload: UpdateMemberProfileData = { const payload: UpdateMemberProfileData = {
...formData, ...formData,
eintrittsdatum: formData.eintrittsdatum ? fromGermanDate(formData.eintrittsdatum) || undefined : undefined, eintrittsdatum: formData.eintrittsdatum ? fromGermanDate(formData.eintrittsdatum) || undefined : undefined,
austrittsdatum: formData.austrittsdatum ? fromGermanDate(formData.austrittsdatum) || undefined : undefined,
geburtsdatum: formData.geburtsdatum ? fromGermanDate(formData.geburtsdatum) || undefined : undefined, geburtsdatum: formData.geburtsdatum ? fromGermanDate(formData.geburtsdatum) || undefined : undefined,
dienstgrad_seit: formData.dienstgrad_seit ? fromGermanDate(formData.dienstgrad_seit) || undefined : undefined, dienstgrad_seit: formData.dienstgrad_seit ? fromGermanDate(formData.dienstgrad_seit) || undefined : undefined,
}; };
@@ -282,12 +288,23 @@ function MitgliedDetail() {
// Reset form to current profile values // Reset form to current profile values
if (member?.profile) { if (member?.profile) {
setFormData({ setFormData({
mitglieds_nr: member.profile.mitglieds_nr ?? undefined,
dienstgrad: member.profile.dienstgrad ?? undefined,
dienstgrad_seit: toGermanDate(member.profile.dienstgrad_seit) || undefined,
funktion: member.profile.funktion,
status: member.profile.status,
eintrittsdatum: toGermanDate(member.profile.eintrittsdatum) || undefined,
austrittsdatum: toGermanDate(member.profile.austrittsdatum) || undefined,
geburtsdatum: toGermanDate(member.profile.geburtsdatum) || undefined,
telefon_mobil: member.profile.telefon_mobil ?? undefined, telefon_mobil: member.profile.telefon_mobil ?? undefined,
telefon_privat: member.profile.telefon_privat ?? undefined, telefon_privat: member.profile.telefon_privat ?? undefined,
notfallkontakt_name: member.profile.notfallkontakt_name ?? undefined, notfallkontakt_name: member.profile.notfallkontakt_name ?? undefined,
notfallkontakt_telefon: member.profile.notfallkontakt_telefon ?? undefined, notfallkontakt_telefon: member.profile.notfallkontakt_telefon ?? undefined,
fuehrerscheinklassen: member.profile.fuehrerscheinklassen,
tshirt_groesse: member.profile.tshirt_groesse ?? undefined, tshirt_groesse: member.profile.tshirt_groesse ?? undefined,
schuhgroesse: member.profile.schuhgroesse ?? undefined, schuhgroesse: member.profile.schuhgroesse ?? undefined,
bemerkungen: member.profile.bemerkungen ?? undefined,
fdisk_standesbuch_nr: member.profile.fdisk_standesbuch_nr ?? undefined,
}); });
} }
}; };
@@ -542,6 +559,35 @@ function MitgliedDetail() {
onChange={(e) => handleFieldChange('geburtsdatum', e.target.value || undefined)} onChange={(e) => handleFieldChange('geburtsdatum', e.target.value || undefined)}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
/> />
<TextField
label="Austrittsdatum"
fullWidth
size="small"
placeholder="TT.MM.JJJJ"
value={formData.austrittsdatum ?? ''}
onChange={(e) => handleFieldChange('austrittsdatum', e.target.value || undefined)}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="Standesbuchnummer"
fullWidth
size="small"
value={formData.fdisk_standesbuch_nr ?? ''}
onChange={(e) => handleFieldChange('fdisk_standesbuch_nr', e.target.value || undefined)}
/>
<Autocomplete
multiple
options={[...FUNKTION_VALUES]}
value={formData.funktion ?? []}
onChange={(_e, newValue) => handleFieldChange('funktion', newValue as FunktionEnum[])}
renderInput={(params) => (
<TextField {...params} label="Funktion" size="small" />
)}
size="small"
/>
</Stack> </Stack>
) : ( ) : (
<> <>
@@ -574,6 +620,13 @@ function MitgliedDetail() {
: null : null
} }
/> />
<FieldRow
label="Austrittsdatum"
value={profile?.austrittsdatum
? new Date(profile.austrittsdatum).toLocaleDateString('de-AT')
: null}
/>
<FieldRow label="Standesbuchnummer" value={profile?.fdisk_standesbuch_nr ?? null} />
</> </>
)} )}
</CardContent> </CardContent>
@@ -700,7 +753,19 @@ function MitgliedDetail() {
title="Führerscheinklassen" title="Führerscheinklassen"
/> />
<CardContent> <CardContent>
{profile?.fuehrerscheinklassen && profile.fuehrerscheinklassen.length > 0 ? ( {editMode && canWrite ? (
<Autocomplete
multiple
freeSolo
options={['AM', 'A', 'B', 'BE', 'C', 'CE', 'C1', 'C1E', 'D', 'DE', 'D1', 'D1E', 'F']}
value={formData.fuehrerscheinklassen ?? []}
onChange={(_e, newValue) => handleFieldChange('fuehrerscheinklassen', newValue)}
renderInput={(params) => (
<TextField {...params} label="Führerscheinklassen" size="small" placeholder="Klasse hinzufügen…" />
)}
size="small"
/>
) : profile?.fuehrerscheinklassen && profile.fuehrerscheinklassen.length > 0 ? (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{profile.fuehrerscheinklassen.map((k) => ( {profile.fuehrerscheinklassen.map((k) => (
<Chip key={k} label={k} size="small" variant="outlined" /> <Chip key={k} label={k} size="small" variant="outlined" />

View File

@@ -28,5 +28,5 @@ export const adminApi = {
broadcast: (data: BroadcastPayload) => api.post<ApiResponse<{ sent: number }>>('/api/admin/notifications/broadcast', data).then(r => r.data.data), broadcast: (data: BroadcastPayload) => api.post<ApiResponse<{ sent: number }>>('/api/admin/notifications/broadcast', data).then(r => r.data.data),
getPingHistory: (serviceId: string) => api.get<ApiResponse<PingHistoryEntry[]>>(`/api/admin/services/${serviceId}/ping-history`).then(r => r.data.data), getPingHistory: (serviceId: string) => api.get<ApiResponse<PingHistoryEntry[]>>(`/api/admin/services/${serviceId}/ping-history`).then(r => r.data.data),
fdiskSyncLogs: () => api.get<ApiResponse<FdiskSyncLogsResponse>>('/api/admin/fdisk-sync/logs').then(r => r.data.data), fdiskSyncLogs: () => api.get<ApiResponse<FdiskSyncLogsResponse>>('/api/admin/fdisk-sync/logs').then(r => r.data.data),
fdiskSyncTrigger: () => api.post<ApiResponse<{ started: boolean }>>('/api/admin/fdisk-sync/trigger').then(r => r.data.data), fdiskSyncTrigger: (force = false) => api.post<ApiResponse<{ started: boolean }>>('/api/admin/fdisk-sync/trigger', { force }).then(r => r.data.data),
}; };

View File

@@ -73,6 +73,7 @@ export interface MitgliederProfile {
tshirt_groesse: TshirtGroesseEnum | null; tshirt_groesse: TshirtGroesseEnum | null;
schuhgroesse: string | null; schuhgroesse: string | null;
bemerkungen: string | null; bemerkungen: string | null;
fdisk_standesbuch_nr: string | null;
bild_url: string | null; bild_url: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;

View File

@@ -54,7 +54,8 @@ function mapDienstgrad(raw: string): string | null {
export async function syncToDatabase( export async function syncToDatabase(
pool: Pool, pool: Pool,
members: FdiskMember[], members: FdiskMember[],
ausbildungen: FdiskAusbildung[] ausbildungen: FdiskAusbildung[],
force = false
): Promise<void> { ): Promise<void> {
const client = await pool.connect(); const client = await pool.connect();
try { try {
@@ -62,6 +63,7 @@ export async function syncToDatabase(
let updated = 0; let updated = 0;
let unchanged = 0; let unchanged = 0;
let forced = 0;
let skipped = 0; let skipped = 0;
for (const member of members) { for (const member of members) {
@@ -161,12 +163,20 @@ export async function syncToDatabase(
if (changes.length > 0) { if (changes.length > 0) {
log(`Updated ${member.vorname} ${member.zuname} (${member.standesbuchNr}): ${changes.join(', ')}`); log(`Updated ${member.vorname} ${member.zuname} (${member.standesbuchNr}): ${changes.join(', ')}`);
updated++; updated++;
} else if (force) {
// Force mode: explicitly update timestamp and log even unchanged rows
await client.query(
`UPDATE mitglieder_profile SET updated_at = NOW() WHERE user_id = $1`,
[userId]
);
log(`Forced update ${member.vorname} ${member.zuname} (${member.standesbuchNr}): no changes, timestamp refreshed`);
forced++;
} else { } else {
unchanged++; unchanged++;
} }
} }
log(`Members: ${updated} changed, ${unchanged} unchanged, ${skipped} skipped (no dashboard account)`); log(`Members: ${updated} changed, ${unchanged} unchanged, ${forced} forced, ${skipped} skipped (no dashboard account)`);
// Upsert Ausbildungen // Upsert Ausbildungen
let ausbildungNew = 0; let ausbildungNew = 0;

View File

@@ -46,7 +46,7 @@ function msUntilMidnight(): number {
return midnight.getTime() - now.getTime(); return midnight.getTime() - now.getTime();
} }
async function runSync(): Promise<void> { async function runSync(force = false): Promise<void> {
if (syncRunning) { if (syncRunning) {
log('Sync already in progress, skipping'); log('Sync already in progress, skipping');
return; return;
@@ -64,9 +64,10 @@ async function runSync(): Promise<void> {
}); });
try { try {
if (force) log('Force mode: ON');
log('Starting FDISK sync'); log('Starting FDISK sync');
const { members, ausbildungen } = await scrapeAll(username, password); const { members, ausbildungen } = await scrapeAll(username, password);
await syncToDatabase(pool, members, ausbildungen); await syncToDatabase(pool, members, ausbildungen, force);
log(`Sync complete — ${members.length} members, ${ausbildungen.length} Ausbildungen`); log(`Sync complete — ${members.length} members, ${ausbildungen.length} Ausbildungen`);
} finally { } finally {
syncRunning = false; syncRunning = false;
@@ -87,9 +88,20 @@ function startHttpServer(port: number) {
res.end(JSON.stringify({ running: true, message: 'Sync already in progress' })); res.end(JSON.stringify({ running: true, message: 'Sync already in progress' }));
return; return;
} }
res.writeHead(200); let body = '';
res.end(JSON.stringify({ started: true })); req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
runSync().catch(err => log(`ERROR during manual sync: ${err.message}`)); req.on('end', () => {
let force = false;
try {
const parsed = JSON.parse(body);
force = parsed?.force === true;
} catch {
// no body or invalid JSON — force stays false
}
res.writeHead(200);
res.end(JSON.stringify({ started: true, force }));
runSync(force).catch(err => log(`ERROR during manual sync: ${err.message}`));
});
} else { } else {
res.writeHead(404); res.writeHead(404);
res.end(JSON.stringify({ message: 'Not found' })); res.end(JSON.stringify({ message: 'Not found' }));