This commit is contained in:
Matthias Hochmeister
2026-03-13 21:49:42 +01:00
parent e666ff434e
commit ef9d2ff4a2
6 changed files with 276 additions and 0 deletions

View File

@@ -230,6 +230,47 @@ class MemberController {
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren des Profils.' }); res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren des Profils.' });
} }
} }
/**
* GET /api/members/:userId/befoerderungen
*/
async getBefoerderungen(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params;
const data = await memberService.getBefoerderungen(userId);
res.status(200).json({ success: true, data });
} catch (error) {
logger.error('getBefoerderungen error', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Beförderungen.' });
}
}
/**
* GET /api/members/:userId/untersuchungen
*/
async getUntersuchungen(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params;
const data = await memberService.getUntersuchungen(userId);
res.status(200).json({ success: true, data });
} catch (error) {
logger.error('getUntersuchungen error', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Untersuchungen.' });
}
}
/**
* GET /api/members/:userId/fahrgenehmigungen
*/
async getFahrgenehmigungen(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params;
const data = await memberService.getFahrgenehmigungen(userId);
res.status(200).json({ success: true, data });
} catch (error) {
logger.error('getFahrgenehmigungen error', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Fahrgenehmigungen.' });
}
}
} }
export default new MemberController(); export default new MemberController();

View File

@@ -39,6 +39,24 @@ router.post(
memberController.createMemberProfile.bind(memberController) memberController.createMemberProfile.bind(memberController)
); );
router.get(
'/:userId/befoerderungen',
requirePermission('members:read'),
memberController.getBefoerderungen.bind(memberController)
);
router.get(
'/:userId/untersuchungen',
requirePermission('members:read'),
memberController.getUntersuchungen.bind(memberController)
);
router.get(
'/:userId/fahrgenehmigungen',
requirePermission('members:read'),
memberController.getFahrgenehmigungen.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.

View File

@@ -626,6 +626,63 @@ class MemberService {
throw new Error('Failed to fetch member stats'); throw new Error('Failed to fetch member stats');
} }
} }
/**
* Returns all Beförderungen for a member, newest first.
*/
async getBefoerderungen(userId: string): Promise<any[]> {
try {
const result = await pool.query(
`SELECT id, datum, dienstgrad, created_at
FROM befoerderungen
WHERE user_id = $1
ORDER BY datum DESC NULLS LAST, created_at DESC`,
[userId]
);
return result.rows;
} catch (error) {
logger.error('Error fetching Beförderungen', { error, userId });
return [];
}
}
/**
* Returns all Untersuchungen for a member, newest first.
*/
async getUntersuchungen(userId: string): Promise<any[]> {
try {
const result = await pool.query(
`SELECT id, datum, anmerkungen, art, ergebnis, created_at
FROM untersuchungen
WHERE user_id = $1
ORDER BY datum DESC NULLS LAST, created_at DESC`,
[userId]
);
return result.rows;
} catch (error) {
logger.error('Error fetching Untersuchungen', { error, userId });
return [];
}
}
/**
* Returns all Fahrgenehmigungen for a member, newest first.
*/
async getFahrgenehmigungen(userId: string): Promise<any[]> {
try {
const result = await pool.query(
`SELECT id, ausstellungsdatum, gueltig_bis, behoerde, nummer, klasse, created_at
FROM fahrgenehmigungen
WHERE user_id = $1
ORDER BY ausstellungsdatum DESC NULLS LAST, created_at DESC`,
[userId]
);
return result.rows;
} catch (error) {
logger.error('Error fetching Fahrgenehmigungen', { error, userId });
return [];
}
}
} }
export default new MemberService(); export default new MemberService();

View File

@@ -33,6 +33,8 @@ import {
DriveEta as DriveEtaIcon, DriveEta as DriveEtaIcon,
CheckCircle as CheckCircleIcon, CheckCircle as CheckCircleIcon,
HighlightOff as HighlightOffIcon, HighlightOff as HighlightOffIcon,
MilitaryTech as MilitaryTechIcon,
LocalHospital as LocalHospitalIcon,
} 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';
@@ -56,6 +58,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 { AtemschutzUebersicht } from '../types/atemschutz.types'; import type { AtemschutzUebersicht } from '../types/atemschutz.types';
import { UntersuchungErgebnisLabel } from '../types/atemschutz.types'; import { UntersuchungErgebnisLabel } from '../types/atemschutz.types';
@@ -237,6 +240,11 @@ function MitgliedDetail() {
const [atemschutz, setAtemschutz] = useState<AtemschutzUebersicht | null>(null); const [atemschutz, setAtemschutz] = useState<AtemschutzUebersicht | null>(null);
const [atemschutzLoading, setAtemschutzLoading] = useState(false); const [atemschutzLoading, setAtemschutzLoading] = useState(false);
// FDISK-synced sub-section data
const [befoerderungen, setBefoerderungen] = useState<Befoerderung[]>([]);
const [untersuchungen, setUntersuchungen] = useState<Untersuchung[]>([]);
const [fahrgenehmigungen, setFahrgenehmigungen] = useState<Fahrgenehmigung[]>([]);
// 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>({});
@@ -271,6 +279,14 @@ function MitgliedDetail() {
.finally(() => setAtemschutzLoading(false)); .finally(() => setAtemschutzLoading(false));
}, [userId]); }, [userId]);
// Load FDISK-synced sub-section data
useEffect(() => {
if (!userId) return;
membersService.getBefoerderungen(userId).then(setBefoerderungen).catch(() => setBefoerderungen([]));
membersService.getUntersuchungen(userId).then(setUntersuchungen).catch(() => setUntersuchungen([]));
membersService.getFahrgenehmigungen(userId).then(setFahrgenehmigungen).catch(() => setFahrgenehmigungen([]));
}, [userId]);
// Populate form from current profile // Populate form from current profile
useEffect(() => { useEffect(() => {
if (member?.profile) { if (member?.profile) {
@@ -1025,6 +1041,102 @@ function MitgliedDetail() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* FDISK-synced sub-sections */}
<Grid container spacing={3} sx={{ mt: 0 }}>
{/* Beförderungen */}
{befoerderungen.length > 0 && (
<Grid item xs={12} md={6}>
<Card>
<CardHeader
avatar={<MilitaryTechIcon color="primary" />}
title="Beförderungen"
/>
<CardContent>
{befoerderungen.map((b) => (
<FieldRow
key={b.id}
label={b.datum ? new Date(b.datum).toLocaleDateString('de-AT') : '—'}
value={b.dienstgrad}
/>
))}
</CardContent>
</Card>
</Grid>
)}
{/* Untersuchungen */}
{untersuchungen.length > 0 && (
<Grid item xs={12} md={6}>
<Card>
<CardHeader
avatar={<LocalHospitalIcon color="primary" />}
title="Untersuchungen"
/>
<CardContent>
{untersuchungen.map((u) => (
<Box key={u.id} sx={{ py: 0.75, borderBottom: '1px solid', borderColor: 'divider' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<Typography variant="body2" fontWeight={500}>
{u.art}
</Typography>
<Typography variant="caption" color="text.secondary">
{u.datum ? new Date(u.datum).toLocaleDateString('de-AT') : '—'}
</Typography>
</Box>
{u.ergebnis && (
<Typography variant="body2" color="text.secondary">
{u.ergebnis}
</Typography>
)}
{u.anmerkungen && (
<Typography variant="caption" color="text.secondary">
{u.anmerkungen}
</Typography>
)}
</Box>
))}
</CardContent>
</Card>
</Grid>
)}
{/* Fahrgenehmigungen */}
{fahrgenehmigungen.length > 0 && (
<Grid item xs={12} md={6}>
<Card>
<CardHeader
avatar={<DriveEtaIcon color="primary" />}
title="Gesetzliche Fahrgenehmigungen"
/>
<CardContent>
{fahrgenehmigungen.map((f) => (
<Box key={f.id} sx={{ py: 0.75, borderBottom: '1px solid', borderColor: 'divider' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<Typography variant="body2" fontWeight={500}>
{f.klasse}
</Typography>
<Typography variant="caption" color="text.secondary">
{f.ausstellungsdatum ? new Date(f.ausstellungsdatum).toLocaleDateString('de-AT') : '—'}
</Typography>
</Box>
{(f.behoerde || f.nummer) && (
<Typography variant="body2" color="text.secondary">
{[f.behoerde, f.nummer].filter(Boolean).join(' · ')}
</Typography>
)}
{f.gueltig_bis && (
<Typography variant="caption" color="text.secondary">
Gültig bis: {new Date(f.gueltig_bis).toLocaleDateString('de-AT')}
</Typography>
)}
</Box>
))}
</CardContent>
</Card>
</Grid>
)}
</Grid>
</TabPanel> </TabPanel>
{/* ---- Tab 2: Einsätze (placeholder) ---- */} {/* ---- Tab 2: Einsätze (placeholder) ---- */}

View File

@@ -6,6 +6,9 @@ import {
MemberStats, MemberStats,
CreateMemberProfileData, CreateMemberProfileData,
UpdateMemberProfileData, UpdateMemberProfileData,
Befoerderung,
Untersuchung,
Fahrgenehmigung,
} from '../types/member.types'; } from '../types/member.types';
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@@ -125,4 +128,19 @@ export const membersService = {
} }
return response.data.data; return response.data.data;
}, },
async getBefoerderungen(userId: string): Promise<Befoerderung[]> {
const response = await api.get<ApiItemResponse<Befoerderung[]>>(`/api/members/${userId}/befoerderungen`);
return response.data?.data ?? [];
},
async getUntersuchungen(userId: string): Promise<Untersuchung[]> {
const response = await api.get<ApiItemResponse<Untersuchung[]>>(`/api/members/${userId}/untersuchungen`);
return response.data?.data ?? [];
},
async getFahrgenehmigungen(userId: string): Promise<Fahrgenehmigung[]> {
const response = await api.get<ApiItemResponse<Fahrgenehmigung[]>>(`/api/members/${userId}/fahrgenehmigungen`);
return response.data?.data ?? [];
},
}; };

View File

@@ -198,3 +198,33 @@ export const STATUS_COLORS: Record<StatusEnum, 'success' | 'warning' | 'error' |
anwärter: 'default', anwärter: 'default',
ausgetreten: 'error', ausgetreten: 'error',
}; };
// ----------------------------------------------------------------
// FDISK-synced sub-section types
// ----------------------------------------------------------------
export interface Befoerderung {
id: string;
datum: string | null;
dienstgrad: string;
created_at: string;
}
export interface Untersuchung {
id: string;
datum: string | null;
anmerkungen: string | null;
art: string;
ergebnis: string | null;
created_at: string;
}
export interface Fahrgenehmigung {
id: string;
ausstellungsdatum: string | null;
gueltig_bis: string | null;
behoerde: string | null;
nummer: string | null;
klasse: string;
created_at: string;
}