From bfcf1556da852d5949f8f2e081db5199d6f7a6fb Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Mon, 23 Mar 2026 15:07:17 +0100 Subject: [PATCH] new features --- .../src/controllers/permission.controller.ts | 19 +++++ backend/src/routes/admin.routes.ts | 61 +++++++++++++++ backend/src/routes/equipment.routes.ts | 4 +- backend/src/routes/permission.routes.ts | 1 + backend/src/routes/vehicle.routes.ts | 4 +- backend/src/services/bestellung.service.ts | 9 ++- backend/src/services/events.service.ts | 62 ++++++++++++++- backend/src/services/permission.service.ts | 25 ++++++ backend/src/services/shop.service.ts | 16 +--- frontend/src/hooks/usePermissions.ts | 2 + frontend/src/pages/AdminDashboard.tsx | 2 +- frontend/src/pages/AusruestungDetail.tsx | 76 ++++++++++++++++++- frontend/src/pages/Bestellungen.tsx | 17 +++-- frontend/src/pages/FahrzeugBuchungen.tsx | 52 +++++++++---- frontend/src/pages/FahrzeugDetail.tsx | 10 ++- frontend/src/pages/Kalender.tsx | 2 + frontend/src/pages/MitgliedDetail.tsx | 2 + frontend/src/pages/Shop.tsx | 38 ++++------ frontend/src/pages/Veranstaltungen.tsx | 10 ++- frontend/src/services/bestellung.ts | 6 ++ frontend/src/services/equipment.ts | 13 ++++ sync/src/scraper.ts | 41 +++++++++- 22 files changed, 397 insertions(+), 75 deletions(-) diff --git a/backend/src/controllers/permission.controller.ts b/backend/src/controllers/permission.controller.ts index 15740f9..6fcdcdc 100644 --- a/backend/src/controllers/permission.controller.ts +++ b/backend/src/controllers/permission.controller.ts @@ -215,6 +215,25 @@ class PermissionController { res.status(500).json({ success: false, message: 'Fehler beim Speichern der Konfiguration' }); } } + + /** + * GET /api/permissions/users-with?permission=bestellungen:create + * Returns users who have a specific permission. + */ + async getUsersWithPermission(req: Request, res: Response): Promise { + try { + const permission = req.query.permission as string; + if (!permission) { + res.status(400).json({ success: false, message: 'permission query parameter required' }); + return; + } + const users = await permissionService.getUsersWithPermission(permission); + res.json({ success: true, data: users }); + } catch (error) { + logger.error('Failed to get users with permission', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Laden der Benutzer' }); + } + } } export default new PermissionController(); diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts index 3405db1..2fde0a5 100644 --- a/backend/src/routes/admin.routes.ts +++ b/backend/src/routes/admin.routes.ts @@ -20,6 +20,7 @@ import { requirePermission } from '../middleware/rbac.middleware'; import { auditExport } from '../middleware/audit.middleware'; import auditService, { AuditAction, AuditResourceType, AuditFilters } from '../services/audit.service'; import cleanupService from '../services/cleanup.service'; +import pool from '../config/database'; import logger from '../utils/logger'; const router = Router(); @@ -264,4 +265,64 @@ router.delete( } ); +// --------------------------------------------------------------------------- +// DELETE /api/admin/users/:userId/sync-data — selective sync data deletion +// --------------------------------------------------------------------------- + +const syncDataBodySchema = z.object({ + types: z.array(z.enum(['profile', 'ausbildung', 'untersuchungen', 'fuehrerschein', 'befoerderungen'])).min(1), +}); + +const SYNC_TABLE_MAP: Record = { + profile: 'mitglieder_profile', + ausbildung: 'ausbildungen', + untersuchungen: 'untersuchungen', + fuehrerschein: 'fahrgenehmigungen', + befoerderungen: 'befoerderungen', +}; + +router.delete( + '/users/:userId/sync-data', + authenticate, + requirePermission('admin:write'), + async (req: Request, res: Response): Promise => { + try { + const userId = req.params.userId; + const { types } = syncDataBodySchema.parse(req.body); + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const results: Record = {}; + + for (const type of types) { + const table = SYNC_TABLE_MAP[type]; + if (!table) continue; + const result = await client.query( + `DELETE FROM ${table} WHERE user_id = $1`, + [userId] + ); + results[type] = result.rowCount ?? 0; + } + + await client.query('COMMIT'); + logger.info('Admin deleted sync data', { userId, types, results, admin: req.user?.id }); + res.json({ success: true, data: results }); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ success: false, message: 'Invalid parameters', errors: error.issues }); + return; + } + logger.error('Failed to delete sync data', { error, userId: req.params.userId }); + res.status(500).json({ success: false, message: 'Fehler beim Löschen der Sync-Daten' }); + } + } +); + export default router; diff --git a/backend/src/routes/equipment.routes.ts b/backend/src/routes/equipment.routes.ts index 8e08fcb..50eaaa1 100644 --- a/backend/src/routes/equipment.routes.ts +++ b/backend/src/routes/equipment.routes.ts @@ -22,8 +22,8 @@ router.get('/:id/status-history', authenticate, equipmentController.getStatusHi router.post('/', authenticate, requirePermission('ausruestung:create'), equipmentController.createEquipment.bind(equipmentController)); router.patch('/:id', authenticate, requirePermission('ausruestung:create'), equipmentController.updateEquipment.bind(equipmentController)); router.patch('/:id/status', authenticate, requirePermission('ausruestung:create'), equipmentController.updateStatus.bind(equipmentController)); -router.post('/:id/wartung', authenticate, requirePermission('ausruestung:create'), equipmentController.addWartung.bind(equipmentController)); -router.post('/wartung/:wartungId/upload', authenticate, requirePermission('ausruestung:create'), uploadWartung.single('datei'), equipmentController.uploadWartungFile.bind(equipmentController)); +router.post('/:id/wartung', authenticate, requirePermission('ausruestung:manage_maintenance'), equipmentController.addWartung.bind(equipmentController)); +router.post('/wartung/:wartungId/upload', authenticate, requirePermission('ausruestung:manage_maintenance'), uploadWartung.single('datei'), equipmentController.uploadWartungFile.bind(equipmentController)); // ── Delete — admin only ────────────────────────────────────────────────────── diff --git a/backend/src/routes/permission.routes.ts b/backend/src/routes/permission.routes.ts index ff67ef7..51c6d29 100644 --- a/backend/src/routes/permission.routes.ts +++ b/backend/src/routes/permission.routes.ts @@ -7,6 +7,7 @@ const router = Router(); // ── User-facing (any authenticated user) ────────────────────────────────── router.get('/me', authenticate, permissionController.getMyPermissions.bind(permissionController)); +router.get('/users-with', authenticate, permissionController.getUsersWithPermission.bind(permissionController)); // ── Admin-only routes ───────────────────────────────────────────────────── router.get('/admin/matrix', authenticate, requirePermission('admin:view'), permissionController.getMatrix.bind(permissionController)); diff --git a/backend/src/routes/vehicle.routes.ts b/backend/src/routes/vehicle.routes.ts index b75bf84..c8a098a 100644 --- a/backend/src/routes/vehicle.routes.ts +++ b/backend/src/routes/vehicle.routes.ts @@ -25,7 +25,7 @@ router.delete('/:id', authenticate, requirePermission('fahrzeuge:delete'), vehic // ── Status + maintenance log — gruppenfuehrer+ ────────────────────────────── router.patch('/:id/status', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.updateVehicleStatus.bind(vehicleController)); -router.post('/:id/wartung', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.addWartung.bind(vehicleController)); -router.post('/wartung/:wartungId/upload', authenticate, requirePermission('fahrzeuge:change_status'), uploadWartung.single('datei'), vehicleController.uploadWartungFile.bind(vehicleController)); +router.post('/:id/wartung', authenticate, requirePermission('fahrzeuge:manage_maintenance'), vehicleController.addWartung.bind(vehicleController)); +router.post('/wartung/:wartungId/upload', authenticate, requirePermission('fahrzeuge:manage_maintenance'), uploadWartung.single('datei'), vehicleController.uploadWartungFile.bind(vehicleController)); export default router; diff --git a/backend/src/services/bestellung.service.ts b/backend/src/services/bestellung.service.ts index d50bf7d..d451673 100644 --- a/backend/src/services/bestellung.service.ts +++ b/backend/src/services/bestellung.service.ts @@ -174,13 +174,14 @@ async function getOrderById(id: number) { } } -async function createOrder(data: { bezeichnung: string; lieferant_id?: number; notizen?: string; budget?: number }, userId: string) { +async function createOrder(data: { bezeichnung: string; lieferant_id?: number; besteller_id?: string; notizen?: string; budget?: number }, userId: string) { try { + const bestellerId = data.besteller_id && data.besteller_id.trim() ? data.besteller_id.trim() : null; const result = await pool.query( - `INSERT INTO bestellungen (bezeichnung, lieferant_id, notizen, budget, erstellt_von) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO bestellungen (bezeichnung, lieferant_id, besteller_id, notizen, budget, erstellt_von) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, - [data.bezeichnung, data.lieferant_id || null, data.notizen || null, data.budget || null, userId] + [data.bezeichnung, data.lieferant_id || null, bestellerId, data.notizen || null, data.budget || null, userId] ); const order = result.rows[0]; await logAction(order.id, 'Bestellung erstellt', `Bestellung "${data.bezeichnung}" erstellt`, userId); diff --git a/backend/src/services/events.service.ts b/backend/src/services/events.service.ts index fa5a73b..a55d501 100644 --- a/backend/src/services/events.service.ts +++ b/backend/src/services/events.service.ts @@ -248,7 +248,8 @@ class EventsService { k.farbe AS kategorie_farbe, k.icon AS kategorie_icon, v.datum_von, v.datum_bis, v.ganztaegig, - v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich + v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich, + v.wiederholung, v.wiederholung_parent_id FROM veranstaltungen v LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id WHERE (v.datum_von BETWEEN $1 AND $2 OR v.datum_bis BETWEEN $1 AND $2 OR (v.datum_von <= $1 AND v.datum_bis >= $2)) @@ -274,7 +275,8 @@ class EventsService { k.farbe AS kategorie_farbe, k.icon AS kategorie_icon, v.datum_von, v.datum_bis, v.ganztaegig, - v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich + v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich, + v.wiederholung, v.wiederholung_parent_id FROM veranstaltungen v LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id WHERE v.datum_von > NOW() @@ -454,6 +456,8 @@ class EventsService { /** * Updates an existing event. + * If the event is a recurrence parent and wiederholung is provided, + * it deletes all future instances and regenerates them. * Returns the updated record or null if not found. */ async updateEvent(id: string, data: UpdateVeranstaltungData): Promise { @@ -475,6 +479,7 @@ class EventsService { max_teilnehmer: data.max_teilnehmer, anmeldung_erforderlich: data.anmeldung_erforderlich, anmeldung_bis: data.anmeldung_bis, + wiederholung: data.wiederholung, }; for (const [col, val] of Object.entries(fieldMap)) { @@ -494,7 +499,58 @@ class EventsService { values ); if (result.rows.length === 0) return null; - return rowToVeranstaltung(result.rows[0]); + + const updated = result.rows[0]; + + // If this is a recurrence parent and wiederholung was updated, regenerate instances + if (data.wiederholung !== undefined && updated.wiederholung_parent_id === null) { + // Delete all existing children of this parent + await pool.query( + `DELETE FROM veranstaltungen WHERE wiederholung_parent_id = $1`, + [id] + ); + + if (data.wiederholung) { + // Regenerate instances from the (possibly new) dates and config + const datumVon = data.datum_von ? new Date(data.datum_von) : new Date(updated.datum_von); + const datumBis = data.datum_bis ? new Date(data.datum_bis) : new Date(updated.datum_bis); + const occurrenceDates = this.generateRecurrenceDates(datumVon, datumBis, data.wiederholung); + if (occurrenceDates.length > 0) { + const duration = datumBis.getTime() - datumVon.getTime(); + for (const occDate of occurrenceDates) { + const occBis = new Date(occDate.getTime() + duration); + await pool.query( + `INSERT INTO veranstaltungen ( + wiederholung_parent_id, titel, beschreibung, ort, ort_url, kategorie_id, + datum_von, datum_bis, ganztaegig, zielgruppen, alle_gruppen, + max_teilnehmer, anmeldung_erforderlich, erstellt_von + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, + [ + id, + updated.titel, + updated.beschreibung ?? null, + updated.ort ?? null, + updated.ort_url ?? null, + updated.kategorie_id ?? null, + occDate, + occBis, + updated.ganztaegig, + updated.zielgruppen, + updated.alle_gruppen, + updated.max_teilnehmer ?? null, + updated.anmeldung_erforderlich, + updated.erstellt_von, + ] + ); + } + logger.info(`Regenerated ${occurrenceDates.length} recurrence instances for event ${id}`); + } + } else { + logger.info(`Removed recurrence from event ${id}, all instances deleted`); + } + } + + return rowToVeranstaltung(updated); } /** diff --git a/backend/src/services/permission.service.ts b/backend/src/services/permission.service.ts index a453089..c42d4c3 100644 --- a/backend/src/services/permission.service.ts +++ b/backend/src/services/permission.service.ts @@ -390,6 +390,31 @@ class PermissionService { ]); return { groupHierarchy, permissionDeps }; } + + /** + * Returns users whose Authentik groups grant a specific permission, + * or who are dashboard_admin (always have all permissions). + */ + async getUsersWithPermission(permissionId: string): Promise> { + // Find all groups that have this permission + const groupsWithPerm: string[] = []; + for (const [group, perms] of this.groupPermissions.entries()) { + if (perms.has(permissionId)) { + groupsWithPerm.push(group); + } + } + // Always include dashboard_admin + groupsWithPerm.push('dashboard_admin'); + + const result = await pool.query( + `SELECT DISTINCT u.id, COALESCE(u.name, u.email) AS name + FROM users u + WHERE u.authentik_groups && $1::text[] + ORDER BY name ASC`, + [groupsWithPerm] + ); + return result.rows; + } } export const permissionService = new PermissionService(); diff --git a/backend/src/services/shop.service.ts b/backend/src/services/shop.service.ts index b4ed0b2..8619d41 100644 --- a/backend/src/services/shop.service.ts +++ b/backend/src/services/shop.service.ts @@ -37,16 +37,15 @@ async function createItem( beschreibung?: string; kategorie?: string; geschaetzter_preis?: number; - url?: string; aktiv?: boolean; }, userId: string, ) { const result = await pool.query( - `INSERT INTO shop_artikel (bezeichnung, beschreibung, kategorie, geschaetzter_preis, url, aktiv, erstellt_von) - VALUES ($1, $2, $3, $4, $5, COALESCE($6, true), $7) + `INSERT INTO shop_artikel (bezeichnung, beschreibung, kategorie, geschaetzter_preis, aktiv, erstellt_von) + VALUES ($1, $2, $3, $4, COALESCE($5, true), $6) RETURNING *`, - [data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzter_preis || null, data.url || null, data.aktiv ?? true, userId], + [data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzter_preis || null, data.aktiv ?? true, userId], ); return result.rows[0]; } @@ -58,10 +57,9 @@ async function updateItem( beschreibung?: string; kategorie?: string; geschaetzter_preis?: number; - url?: string; aktiv?: boolean; }, - userId: string, + _userId: string, ) { const fields: string[] = []; const params: unknown[] = []; @@ -82,10 +80,6 @@ async function updateItem( params.push(data.geschaetzter_preis); fields.push(`geschaetzter_preis = $${params.length}`); } - if (data.url !== undefined) { - params.push(data.url); - fields.push(`url = $${params.length}`); - } if (data.aktiv !== undefined) { params.push(data.aktiv); fields.push(`aktiv = $${params.length}`); @@ -95,8 +89,6 @@ async function updateItem( return getItemById(id); } - params.push(userId); - fields.push(`aktualisiert_von = $${params.length}`); params.push(new Date()); fields.push(`aktualisiert_am = $${params.length}`); diff --git a/frontend/src/hooks/usePermissions.ts b/frontend/src/hooks/usePermissions.ts index c02cb3c..0336a02 100644 --- a/frontend/src/hooks/usePermissions.ts +++ b/frontend/src/hooks/usePermissions.ts @@ -19,6 +19,8 @@ export function usePermissions() { isFahrmeister: false, // No longer needed — use hasPermission() instead isZeugmeister: false, // No longer needed — use hasPermission() instead canChangeStatus: hasPermission('fahrzeuge:change_status'), + canManageMaintenance: hasPermission('fahrzeuge:manage_maintenance'), + canManageEquipmentMaintenance: hasPermission('ausruestung:manage_maintenance'), canManageEquipment: hasPermission('ausruestung:create'), canManageMotorizedEquipment: hasPermission('ausruestung:create'), canManageNonMotorizedEquipment: hasPermission('ausruestung:create'), diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index 2a4973a..2cd2804 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -50,7 +50,7 @@ function AdminDashboard() { Administration - { setTab(v); navigate(`/admin?tab=${v}`, { replace: true }); }}> + { setTab(v); navigate(`/admin?tab=${v}`, { replace: true }); }} variant="scrollable" scrollButtons="auto"> diff --git a/frontend/src/pages/AusruestungDetail.tsx b/frontend/src/pages/AusruestungDetail.tsx index d7861d5..f0a6bd5 100644 --- a/frontend/src/pages/AusruestungDetail.tsx +++ b/frontend/src/pages/AusruestungDetail.tsx @@ -21,6 +21,12 @@ import { Select, Stack, Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, Tabs, TextField, Tooltip, @@ -34,6 +40,7 @@ import { DeleteOutline, Edit, Error as ErrorIcon, + History, MoreHoriz, PauseCircle, RemoveCircle, @@ -97,6 +104,65 @@ function fmtDate(iso: string | null | undefined): string { }); } +function fmtDatetime(iso: string | null | undefined): string { + if (!iso) return '---'; + return new Date(iso).toLocaleString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', + }); +} + +// -- Status History Section --------------------------------------------------- + +const StatusHistorySection: React.FC<{ equipmentId: string }> = ({ equipmentId }) => { + const [history, setHistory] = useState<{ alter_status: string; neuer_status: string; bemerkung?: string; geaendert_von_name?: string; erstellt_am: string }[]>([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + equipmentApi.getStatusHistory(equipmentId) + .then(setHistory) + .catch(() => setHistory([])) + .finally(() => setLoading(false)); + }, [equipmentId]); + + if (loading || history.length === 0) return null; + + return ( + <> + + Status-Verlauf + + + + + + Datum + Von + Nach + Bemerkung + Geändert von + + + + {history.map((h, idx) => ( + + {fmtDatetime(h.erstellt_am)} + + + + + + + {h.bemerkung || '—'} + {h.geaendert_von_name || '—'} + + ))} + +
+
+ + ); +}; + // -- Wartungslog Art config --------------------------------------------------- const WARTUNG_ART_CHIP_COLOR: Record = { @@ -337,6 +403,8 @@ const UebersichtTab: React.FC = ({ equipment, onStatusUpdate + +
); }; @@ -430,7 +498,7 @@ const WartungTab: React.FC = ({ equipmentId, wartungslog, onAdd {entry.beschreibung} {[ - entry.kosten != null && `${entry.kosten.toFixed(2)} EUR`, + entry.kosten != null && `${Number(entry.kosten).toFixed(2)} EUR`, entry.pruefende_stelle && entry.pruefende_stelle, ].filter(Boolean).join(' · ')} @@ -563,7 +631,7 @@ const WartungTab: React.FC = ({ equipmentId, wartungslog, onAdd function AusruestungDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { isAdmin, canManageCategory } = usePermissions(); + const { isAdmin, canManageCategory, canManageEquipmentMaintenance } = usePermissions(); const notification = useNotification(); const [equipment, setEquipment] = useState(null); @@ -706,6 +774,8 @@ function AusruestungDetailPage() { value={activeTab} onChange={(_, v) => setActiveTab(v)} aria-label="Ausrüstung Detailansicht" + variant="scrollable" + scrollButtons="auto" > diff --git a/frontend/src/pages/Bestellungen.tsx b/frontend/src/pages/Bestellungen.tsx index 0387309..5453f78 100644 --- a/frontend/src/pages/Bestellungen.tsx +++ b/frontend/src/pages/Bestellungen.tsx @@ -109,6 +109,11 @@ export default function Bestellungen() { queryFn: bestellungApi.getVendors, }); + const { data: orderUsers = [] } = useQuery({ + queryKey: ['bestellungen', 'order-users'], + queryFn: bestellungApi.getOrderUsers, + }); + // ── Mutations ── const createOrder = useMutation({ mutationFn: (data: BestellungFormData) => bestellungApi.createOrder(data), @@ -194,7 +199,7 @@ export default function Bestellungen() { Bestellungen - { setTab(v); navigate(`/bestellungen?tab=${v}`, { replace: true }); }}> + { setTab(v); navigate(`/bestellungen?tab=${v}`, { replace: true }); }} variant="scrollable" scrollButtons="auto"> @@ -366,10 +371,12 @@ export default function Bestellungen() { )} - setOrderForm((f) => ({ ...f, besteller_id: e.target.value }))} + o.name} + value={orderUsers.find((u) => u.id === orderForm.besteller_id) || null} + onChange={(_e, v) => setOrderForm((f) => ({ ...f, besteller_id: v?.id || '' }))} + renderInput={(params) => } /> { - if (!cancelled) setAvailability(result); - }) - .catch(() => { - if (!cancelled) setAvailability(null); - }); + const timer = setTimeout(() => { + bookingApi + .checkAvailability( + form.fahrzeugId, + beginn, + ende, + editingBooking?.id + ) + .then((result) => { + if (!cancelled) setAvailability(result); + }) + .catch(() => { + if (!cancelled) setAvailability(null); + }); + }, 300); return () => { cancelled = true; + clearTimeout(timer); }; }, [form.fahrzeugId, form.beginn, form.ende, editingBooking?.id]); @@ -698,6 +707,23 @@ function FahrzeugBuchungen() { Von: {detailBooking.gebucht_von_name} )} + {(() => { + const mw = maintenanceWindows.find((w) => w.id === detailBooking.fahrzeug_id); + if (mw?.ausser_dienst_von && mw?.ausser_dienst_bis) { + const bookingStart = new Date(detailBooking.beginn); + const bookingEnd = new Date(detailBooking.ende); + const serviceStart = new Date(mw.ausser_dienst_von); + const serviceEnd = new Date(mw.ausser_dienst_bis); + if (bookingStart < serviceEnd && bookingEnd > serviceStart) { + return ( + + Fahrzeug außer Dienst: {format(serviceStart, 'dd.MM.')} – {format(serviceEnd, 'dd.MM.yyyy')} + + ); + } + } + return null; + })()} {(canWrite || (canCancelOwn && detailBooking.gebucht_von === user?.id)) && ( {canWrite && ( diff --git a/frontend/src/pages/FahrzeugDetail.tsx b/frontend/src/pages/FahrzeugDetail.tsx index 0d280c8..bfd0424 100644 --- a/frontend/src/pages/FahrzeugDetail.tsx +++ b/frontend/src/pages/FahrzeugDetail.tsx @@ -526,8 +526,8 @@ const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdde {[ entry.km_stand != null && `${entry.km_stand.toLocaleString('de-DE')} km`, - entry.kraftstoff_liter != null && `${entry.kraftstoff_liter.toFixed(1)} L`, - entry.kosten != null && `${entry.kosten.toFixed(2)} €`, + entry.kraftstoff_liter != null && `${Number(entry.kraftstoff_liter).toFixed(1)} L`, + entry.kosten != null && `${Number(entry.kosten).toFixed(2)} €`, entry.externe_werkstatt && entry.externe_werkstatt, ].filter(Boolean).join(' · ')} @@ -818,7 +818,7 @@ const AusruestungTab: React.FC = ({ equipment, vehicleId: _ function FahrzeugDetail() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { isAdmin, canChangeStatus } = usePermissions(); + const { isAdmin, canChangeStatus, canManageMaintenance } = usePermissions(); const notification = useNotification(); const [vehicle, setVehicle] = useState(null); @@ -959,6 +959,8 @@ function FahrzeugDetail() { value={activeTab} onChange={(_, v) => setActiveTab(v)} aria-label="Fahrzeug Detailansicht" + variant="scrollable" + scrollButtons="auto" > diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index 0e577cb..277e997 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -2172,6 +2172,8 @@ export default function Kalender() { value={activeTab} onChange={(_, v) => { setActiveTab(v); navigate(`/kalender?tab=${v}`, { replace: true }); }} sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }} + variant="scrollable" + scrollButtons="auto" > } iconPosition="start" label="Dienste & Veranstaltungen" /> } iconPosition="start" label="Fahrzeugbuchungen" /> diff --git a/frontend/src/pages/MitgliedDetail.tsx b/frontend/src/pages/MitgliedDetail.tsx index 6fe5398..06d52c1 100644 --- a/frontend/src/pages/MitgliedDetail.tsx +++ b/frontend/src/pages/MitgliedDetail.tsx @@ -575,6 +575,8 @@ function MitgliedDetail() { value={activeTab} onChange={(_e, v) => setActiveTab(v)} aria-label="Mitglied Details" + variant="scrollable" + scrollButtons="auto" > diff --git a/frontend/src/pages/Shop.tsx b/frontend/src/pages/Shop.tsx index ef6f949..73fe78a 100644 --- a/frontend/src/pages/Shop.tsx +++ b/frontend/src/pages/Shop.tsx @@ -23,8 +23,6 @@ import { SHOP_STATUS_LABELS, SHOP_STATUS_COLORS } from '../types/shop.types'; import type { ShopArtikel, ShopArtikelFormData, ShopAnfrageFormItem, ShopAnfrageDetailResponse, ShopAnfrageStatus } from '../types/shop.types'; import type { Bestellung } from '../types/bestellung.types'; -const priceFormat = new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }); - // ─── Catalog Tab ──────────────────────────────────────────────────────────── interface DraftItem { @@ -116,7 +114,7 @@ function KatalogTab() { }; const openEditArtikel = (a: ShopArtikel) => { setEditArtikel(a); - setArtikelForm({ bezeichnung: a.bezeichnung, beschreibung: a.beschreibung, kategorie: a.kategorie, geschaetzter_preis: a.geschaetzter_preis }); + setArtikelForm({ bezeichnung: a.bezeichnung, beschreibung: a.beschreibung, kategorie: a.kategorie }); setArtikelDialogOpen(true); }; const saveArtikel = () => { @@ -173,12 +171,11 @@ function KatalogTab() { {item.bezeichnung} {item.beschreibung && {item.beschreibung}} - - {item.kategorie && } - {item.geschaetzter_preis != null && ( - ca. {priceFormat.format(item.geschaetzter_preis)} - )} - + {item.kategorie && ( + + + + )} {canCreate && ( @@ -197,10 +194,12 @@ function KatalogTab() { )} - {/* Custom item + draft summary */} - {canCreate && draft.length > 0 && ( + {/* Free-text item + draft summary */} + {canCreate && ( - Anfrage-Entwurf + + {draft.length > 0 ? 'Anfrage-Entwurf' : 'Freitext-Position hinzufügen'} + {draft.map((d, idx) => ( {d.bezeichnung} @@ -208,12 +207,14 @@ function KatalogTab() { removeDraftItem(idx)}> ))} - + {draft.length > 0 && } setCustomText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addCustomToDraft(); }} sx={{ flexGrow: 1 }} /> - + {draft.length > 0 && ( + + )} )} @@ -246,13 +247,6 @@ function KatalogTab() { onInputChange={(_, val) => setArtikelForm(f => ({ ...f, kategorie: val || undefined }))} renderInput={params => } /> - setArtikelForm(f => ({ ...f, geschaetzter_preis: e.target.value ? Number(e.target.value) : undefined }))} - inputProps={{ min: 0, step: 0.01 }} - /> @@ -607,7 +601,7 @@ export default function Shop() { Shop - + {canCreate && } {canApprove && } diff --git a/frontend/src/pages/Veranstaltungen.tsx b/frontend/src/pages/Veranstaltungen.tsx index fb168f0..b6596f7 100644 --- a/frontend/src/pages/Veranstaltungen.tsx +++ b/frontend/src/pages/Veranstaltungen.tsx @@ -598,6 +598,7 @@ function EventFormDialog({ max_teilnehmer: null, anmeldung_erforderlich: editingEvent.anmeldung_erforderlich, anmeldung_bis: null, + wiederholung: editingEvent.wiederholung ?? undefined, }); } else { const now = new Date(); @@ -927,10 +928,15 @@ function EventFormDialog({ fullWidth /> - {/* Recurrence / Wiederholung — only for new events */} - {!editingEvent && ( + {/* Recurrence / Wiederholung — for new events or when editing a parent event */} + {(!editingEvent || (editingEvent.wiederholung && !editingEvent.wiederholung_parent_id)) && ( <> + {editingEvent && ( + + Änderungen an der Wiederholung werden alle bestehenden Instanzen löschen und neu generieren. + + )} > => { + const r = await api.get('/api/permissions/users-with', { params: { permission: 'bestellungen:create' } }); + return r.data.data; + }, }; diff --git a/frontend/src/services/equipment.ts b/frontend/src/services/equipment.ts index b885969..b722758 100644 --- a/frontend/src/services/equipment.ts +++ b/frontend/src/services/equipment.ts @@ -108,4 +108,17 @@ export const equipmentApi = { } return response.data.data; }, + + async getStatusHistory(id: string): Promise> { + const response = await api.get<{ success: boolean; data: any[] }>( + `/api/equipment/${id}/status-history` + ); + return response.data.data ?? []; + }, }; diff --git a/sync/src/scraper.ts b/sync/src/scraper.ts index 7f72de6..18cb03c 100644 --- a/sync/src/scraper.ts +++ b/sync/src/scraper.ts @@ -733,7 +733,7 @@ async function scrapeAusbildungenFromDetailPage( return []; } - const url = `${BASE_URL}/fdisk/module/mgvw/ausbildungen/AusbildungenListEdit.aspx` + const url = `${BASE_URL}/fdisk/module/mgvw/kursteilnehmer/KursteilnehmerListEdit.aspx` + `?search=1&searchid_mitgliedschaften=${idMitgliedschaft}&id_personen=${idPersonen}` + `&id_mitgliedschaften=${idMitgliedschaft}&searchid_personen=${idPersonen}&searchid_maskmode=`; @@ -1084,7 +1084,35 @@ async function scrapeMemberUntersuchungen( }).catch(() => null); if (hasHistoryLink) { - log(` → Found history link: ${hasHistoryLink}`); + log(` → Found history link: ${hasHistoryLink}, navigating...`); + // Try to click or navigate to the history page for more complete data + try { + const navigated = await frame.evaluate(() => { + const links = Array.from(document.querySelectorAll('a, input[type="button"], button')); + for (const el of links) { + const text = (el.textContent || '').toLowerCase(); + const title = (el.getAttribute('title') || '').toLowerCase(); + if (text.includes('verlauf') || text.includes('historie') || text.includes('alle anzeigen') + || title.includes('verlauf') || title.includes('historie')) { + if ((el as HTMLAnchorElement).href) { + return (el as HTMLAnchorElement).href; + } + (el as HTMLElement).click(); + return 'clicked'; + } + } + return null; + }).catch(() => null); + if (navigated && navigated !== 'clicked') { + await frame_goto(frame, navigated); + } else if (navigated === 'clicked') { + await frame.waitForNavigation({ timeout: 5000 }).catch(() => {}); + } + await selectAlleAnzeige(frame); + await dumpHtml(frame, `untersuchungen_history_StNr${standesbuchNr}`); + } catch (e) { + log(` → Failed to follow history link: ${e}`); + } } // Parse the table using navigateAndGetTableRows logic (reuse existing page state) @@ -1262,10 +1290,19 @@ async function scrapeMemberFahrgenehmigungen( // If form-field approach found rows, use them if (rawRows.length > 0) { + const VALID_LICENSE_CLASSES = new Set([ + 'A', 'A1', 'A2', 'AM', 'B', 'B1', 'BE', 'C', 'C1', 'CE', 'C1E', + 'D', 'D1', 'DE', 'D1E', 'F', 'G', 'L', 'T', + ]); const results: FdiskFahrgenehmigung[] = []; for (const row of rawRows) { const klasse = cellText(row.klasse); if (!klasse) continue; + // Validate klasse against whitelist — skip non-class data + if (!VALID_LICENSE_CLASSES.has(klasse.toUpperCase())) { + log(` → Skipping invalid klasse: "${klasse}"`); + continue; + } const ausstellungsdatum = parseDate(row.ausstellungsdatum); const syncKey = `${standesbuchNr}::${klasse}::${ausstellungsdatum ?? ''}`; results.push({