add features

This commit is contained in:
Matthias Hochmeister
2026-03-03 17:01:53 +01:00
parent 92b05726d4
commit 5a6fc85a75
30 changed files with 1104 additions and 198 deletions

View File

@@ -80,6 +80,7 @@ import nextcloudRoutes from './routes/nextcloud.routes';
import atemschutzRoutes from './routes/atemschutz.routes';
import eventsRoutes from './routes/events.routes';
import bookingRoutes from './routes/booking.routes';
import notificationRoutes from './routes/notification.routes';
app.use('/api/auth', authRoutes);
app.use('/api/user', userRoutes);
@@ -93,6 +94,7 @@ app.use('/api/atemschutz', atemschutzRoutes);
app.use('/api/nextcloud/talk', nextcloudRoutes);
app.use('/api/events', eventsRoutes);
app.use('/api/bookings', bookingRoutes);
app.use('/api/notifications', notificationRoutes);
// 404 handler
app.use(notFoundHandler);

View File

@@ -89,6 +89,28 @@ function getUserId(req: Request): string {
return req.user!.id;
}
function getUserGroups(req: Request): string[] {
return req.user?.groups ?? [];
}
/**
* Returns true if the user is authorised to write to equipment in the given
* category. Admin can write to any category. Fahrmeister can only write to
* motorised categories. Zeugmeister can only write to non-motorised categories.
*/
async function checkCategoryPermission(kategorieId: string, groups: string[]): Promise<boolean> {
if (groups.includes('dashboard_admin')) return true;
const result = await equipmentService.getCategoryById(kategorieId);
if (!result) return false; // unknown category → deny
if (result.motorisiert) {
return groups.includes('dashboard_fahrmeister');
} else {
return groups.includes('dashboard_zeugmeister');
}
}
// ── Controller ────────────────────────────────────────────────────────────────
class EquipmentController {
@@ -193,6 +215,12 @@ class EquipmentController {
});
return;
}
const groups = getUserGroups(req);
const allowed = await checkCategoryPermission(parsed.data.kategorie_id, groups);
if (!allowed) {
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' });
return;
}
const equipment = await equipmentService.createEquipment(parsed.data, getUserId(req));
res.status(201).json({ success: true, data: equipment });
} catch (error) {
@@ -221,6 +249,25 @@ class EquipmentController {
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
return;
}
// Determine which category to check permissions against
const groups = getUserGroups(req);
if (!groups.includes('dashboard_admin')) {
// If kategorie_id is being changed, check against the new category; otherwise fetch existing
let kategorieId = parsed.data.kategorie_id;
if (!kategorieId) {
const existing = await equipmentService.getEquipmentById(id);
if (!existing) {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
kategorieId = existing.kategorie_id;
}
const allowed = await checkCategoryPermission(kategorieId, groups);
if (!allowed) {
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' });
return;
}
}
const equipment = await equipmentService.updateEquipment(id, parsed.data, getUserId(req));
if (!equipment) {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
@@ -253,6 +300,19 @@ class EquipmentController {
});
return;
}
const groups = getUserGroups(req);
if (!groups.includes('dashboard_admin')) {
const existing = await equipmentService.getEquipmentById(id);
if (!existing) {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
const allowed = await checkCategoryPermission(existing.kategorie_id, groups);
if (!allowed) {
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' });
return;
}
}
await equipmentService.updateStatus(
id, parsed.data.status, parsed.data.bemerkung, getUserId(req)
);
@@ -302,6 +362,19 @@ class EquipmentController {
});
return;
}
const groups = getUserGroups(req);
if (!groups.includes('dashboard_admin')) {
const existing = await equipmentService.getEquipmentById(id);
if (!existing) {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
const allowed = await checkCategoryPermission(existing.kategorie_id, groups);
if (!allowed) {
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' });
return;
}
}
const entry = await equipmentService.addWartungslog(id, parsed.data, getUserId(req));
res.status(201).json({ success: true, data: entry });
} catch (error: any) {

View File

@@ -0,0 +1,72 @@
// =============================================================================
// Notification Controller
// =============================================================================
import { Request, Response } from 'express';
import notificationService from '../services/notification.service';
import logger from '../utils/logger';
function isValidUUID(s: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
}
class NotificationController {
/** GET /api/notifications — returns all notifications for the authenticated user. */
async getNotifications(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.id;
const notifications = await notificationService.getByUser(userId);
res.status(200).json({ success: true, data: notifications });
} catch (error) {
logger.error('NotificationController.getNotifications error', { error });
res.status(500).json({ success: false, message: 'Notifications konnten nicht geladen werden' });
}
}
/** GET /api/notifications/count — returns unread count for the authenticated user. */
async getUnreadCount(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.id;
const count = await notificationService.getUnreadCount(userId);
res.status(200).json({ success: true, data: { count } });
} catch (error) {
logger.error('NotificationController.getUnreadCount error', { error });
res.status(500).json({ success: false, message: 'Anzahl konnte nicht geladen werden' });
}
}
/** PATCH /api/notifications/:id/read — marks a single notification as read. */
async markAsRead(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Notification-ID' });
return;
}
const userId = req.user!.id;
const updated = await notificationService.markAsRead(id, userId);
if (!updated) {
res.status(404).json({ success: false, message: 'Notification nicht gefunden' });
return;
}
res.status(200).json({ success: true, message: 'Als gelesen markiert' });
} catch (error) {
logger.error('NotificationController.markAsRead error', { error });
res.status(500).json({ success: false, message: 'Notification konnte nicht aktualisiert werden' });
}
}
/** POST /api/notifications/mark-all-read — marks all notifications as read. */
async markAllRead(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.id;
await notificationService.markAllRead(userId);
res.status(200).json({ success: true, message: 'Alle als gelesen markiert' });
} catch (error) {
logger.error('NotificationController.markAllRead error', { error });
res.status(500).json({ success: false, message: 'Notifications konnten nicht aktualisiert werden' });
}
}
}
export default new NotificationController();

View File

@@ -0,0 +1,32 @@
-- Migration 020: Create notifications table
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
typ VARCHAR(50) NOT NULL,
titel VARCHAR(500) NOT NULL,
nachricht TEXT NOT NULL,
schwere VARCHAR(20) NOT NULL DEFAULT 'info'
CHECK (schwere IN ('info', 'warnung', 'fehler')),
gelesen BOOLEAN NOT NULL DEFAULT FALSE,
gelesen_am TIMESTAMPTZ,
link VARCHAR(500),
quell_id VARCHAR(100),
quell_typ VARCHAR(50),
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Fast lookup for unread badge count
CREATE INDEX IF NOT EXISTS notifications_user_unread_idx
ON notifications (user_id, gelesen)
WHERE NOT gelesen;
-- Fast lookup for notification list ordered by date
CREATE INDEX IF NOT EXISTS notifications_user_date_idx
ON notifications (user_id, erstellt_am DESC);
-- Dedup index: one unread notification per (user, source type, source id)
CREATE UNIQUE INDEX IF NOT EXISTS notifications_dedup_idx
ON notifications (user_id, quell_typ, quell_id)
WHERE NOT gelesen AND quell_typ IS NOT NULL AND quell_id IS NOT NULL;

View File

@@ -0,0 +1,25 @@
-- Migration 021: Add motorisiert flag to ausruestung_kategorien
-- Motorized equipment (motorisiert = TRUE) → managed by fahrmeister
-- Non-motorized equipment (motorisiert = FALSE) → managed by zeugmeister
ALTER TABLE ausruestung_kategorien
ADD COLUMN IF NOT EXISTS motorisiert BOOLEAN NOT NULL DEFAULT FALSE;
-- Recreate the helper view to include the new column
CREATE OR REPLACE VIEW ausruestung_mit_pruefstatus AS
SELECT
a.*,
k.name AS kategorie_name,
k.kurzname AS kategorie_kurzname,
k.motorisiert AS kategorie_motorisiert,
f.bezeichnung AS fahrzeug_bezeichnung,
f.kurzname AS fahrzeug_kurzname,
CASE
WHEN a.naechste_pruefung_am IS NOT NULL
THEN a.naechste_pruefung_am::date - CURRENT_DATE
ELSE NULL
END AS pruefung_tage_bis_faelligkeit
FROM ausruestung a
JOIN ausruestung_kategorien k ON k.id = a.kategorie_id
LEFT JOIN fahrzeuge f ON f.id = a.fahrzeug_id AND f.deleted_at IS NULL
WHERE a.deleted_at IS NULL;

View File

@@ -0,0 +1,6 @@
-- Migration 022: Add alle_gruppen flag to veranstaltung_kategorien
-- When alle_gruppen = TRUE, selecting this category auto-fills
-- the event's alle_gruppen = TRUE and clears individual zielgruppen.
ALTER TABLE veranstaltung_kategorien
ADD COLUMN IF NOT EXISTS alle_gruppen BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,263 @@
/**
* Notification Generation Job
*
* Runs every 15 minutes and generates persistent notifications for:
* 1. Personal atemschutz warnings (untersuchung / leistungstest expiring within 60 days)
* 2. Vehicle issues (for fahrmeister users)
* 3. Equipment issues (for fahrmeister if motorised, zeugmeister if not)
*
* Deduplicates via the unique index on (user_id, quell_typ, quell_id) WHERE NOT gelesen.
* Also cleans up read notifications older than 90 days.
*/
import pool from '../config/database';
import notificationService from '../services/notification.service';
import logger from '../utils/logger';
const INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
const ATEMSCHUTZ_THRESHOLD = 60; // days
let jobInterval: ReturnType<typeof setInterval> | null = null;
// ---------------------------------------------------------------------------
// Core generation function
// ---------------------------------------------------------------------------
export async function runNotificationGeneration(): Promise<void> {
try {
await generateAtemschutzNotifications();
await generateVehicleNotifications();
await generateEquipmentNotifications();
await notificationService.deleteOldRead();
} catch (error) {
logger.error('NotificationGenerationJob: unexpected error', {
error: error instanceof Error ? error.message : String(error),
});
}
}
// ---------------------------------------------------------------------------
// 1. Atemschutz personal warnings
// ---------------------------------------------------------------------------
async function generateAtemschutzNotifications(): Promise<void> {
try {
// Get all atemschutz records with expiring dates
const result = await pool.query(`
SELECT
au.user_id,
au.untersuchung_tage_rest,
au.leistungstest_tage_rest,
u.name AS user_name
FROM atemschutz_uebersicht au
JOIN users u ON u.id = au.user_id
WHERE au.user_id IS NOT NULL
`);
for (const row of result.rows) {
const userId = row.user_id;
const untTage = row.untersuchung_tage_rest != null ? parseInt(row.untersuchung_tage_rest, 10) : null;
const leiTage = row.leistungstest_tage_rest != null ? parseInt(row.leistungstest_tage_rest, 10) : null;
if (untTage !== null && untTage <= ATEMSCHUTZ_THRESHOLD) {
const schwere = untTage < 0 ? 'fehler' : untTage <= 14 ? 'warnung' : 'info';
await notificationService.createNotification({
user_id: userId,
typ: 'atemschutz_untersuchung',
titel: 'Atemschutz-Untersuchung fällig',
nachricht: untTage < 0
? `Deine Atemschutz-Untersuchung ist seit ${Math.abs(untTage)} Tagen überfällig.`
: `Deine Atemschutz-Untersuchung ist in ${untTage} Tagen fällig.`,
schwere: schwere as any,
link: '/atemschutz',
quell_id: `atemschutz-untersuchung-${userId}`,
quell_typ: 'atemschutz_untersuchung',
});
}
if (leiTage !== null && leiTage <= ATEMSCHUTZ_THRESHOLD) {
const schwere = leiTage < 0 ? 'fehler' : leiTage <= 14 ? 'warnung' : 'info';
await notificationService.createNotification({
user_id: userId,
typ: 'atemschutz_leistungstest',
titel: 'Atemschutz-Leistungstest fällig',
nachricht: leiTage < 0
? `Dein Atemschutz-Leistungstest ist seit ${Math.abs(leiTage)} Tagen überfällig.`
: `Dein Atemschutz-Leistungstest ist in ${leiTage} Tagen fällig.`,
schwere: schwere as any,
link: '/atemschutz',
quell_id: `atemschutz-leistungstest-${userId}`,
quell_typ: 'atemschutz_leistungstest',
});
}
}
} catch (error) {
logger.error('NotificationGenerationJob: generateAtemschutzNotifications failed', { error });
}
}
// ---------------------------------------------------------------------------
// 2. Vehicle issues → fahrmeister users
// ---------------------------------------------------------------------------
async function generateVehicleNotifications(): Promise<void> {
try {
// Find vehicles with problems (damaged or not operational, or overdue inspection)
const vehiclesResult = await pool.query(`
SELECT id, bezeichnung, kurzname, status, naechste_pruefung_tage
FROM fahrzeuge
WHERE deleted_at IS NULL
AND (
status IN ('beschaedigt', 'ausser_dienst')
OR (naechste_pruefung_tage IS NOT NULL AND naechste_pruefung_tage::int < 0)
)
`);
if (vehiclesResult.rows.length === 0) return;
// Get all fahrmeister users
const usersResult = await pool.query(`
SELECT id FROM users WHERE is_active = TRUE AND 'dashboard_fahrmeister' = ANY(authentik_groups)
`);
for (const user of usersResult.rows) {
for (const vehicle of vehiclesResult.rows) {
const label = vehicle.kurzname ? `${vehicle.bezeichnung} (${vehicle.kurzname})` : vehicle.bezeichnung;
const isOverdueInspection = vehicle.naechste_pruefung_tage != null && parseInt(vehicle.naechste_pruefung_tage, 10) < 0;
const isBroken = ['beschaedigt', 'ausser_dienst'].includes(vehicle.status);
if (isBroken) {
await notificationService.createNotification({
user_id: user.id,
typ: 'fahrzeug_status',
titel: `Fahrzeug nicht einsatzbereit`,
nachricht: `${label} hat den Status "${vehicle.status}" und ist nicht einsatzbereit.`,
schwere: 'fehler',
link: `/fahrzeuge/${vehicle.id}`,
quell_id: `fahrzeug-status-${vehicle.id}`,
quell_typ: 'fahrzeug_status',
});
}
if (isOverdueInspection) {
const tage = Math.abs(parseInt(vehicle.naechste_pruefung_tage, 10));
await notificationService.createNotification({
user_id: user.id,
typ: 'fahrzeug_pruefung',
titel: `Fahrzeugprüfung überfällig`,
nachricht: `Die Prüfung von ${label} ist seit ${tage} Tagen überfällig.`,
schwere: 'fehler',
link: `/fahrzeuge/${vehicle.id}`,
quell_id: `fahrzeug-pruefung-${vehicle.id}`,
quell_typ: 'fahrzeug_pruefung',
});
}
}
}
} catch (error) {
logger.error('NotificationGenerationJob: generateVehicleNotifications failed', { error });
}
}
// ---------------------------------------------------------------------------
// 3. Equipment issues → fahrmeister (motorised) or zeugmeister (non-motorised)
// ---------------------------------------------------------------------------
async function generateEquipmentNotifications(): Promise<void> {
try {
// Find equipment with problems (broken, overdue inspection)
const equipmentResult = await pool.query(`
SELECT
a.id, a.bezeichnung, a.status,
k.motorisiert,
(a.naechste_pruefung_am::date - CURRENT_DATE) AS pruefung_tage
FROM ausruestung a
JOIN ausruestung_kategorien k ON k.id = a.kategorie_id
WHERE a.deleted_at IS NULL
AND (
a.status IN ('beschaedigt', 'ausser_dienst')
OR (a.naechste_pruefung_am IS NOT NULL AND a.naechste_pruefung_am::date < CURRENT_DATE)
)
`);
if (equipmentResult.rows.length === 0) return;
// Get fahrmeister and zeugmeister users
const [fahrResult, zeugResult] = await Promise.all([
pool.query(`SELECT id FROM users WHERE is_active = TRUE AND 'dashboard_fahrmeister' = ANY(authentik_groups)`),
pool.query(`SELECT id FROM users WHERE is_active = TRUE AND 'dashboard_zeugmeister' = ANY(authentik_groups)`),
]);
const fahrmeisterIds: string[] = fahrResult.rows.map((r: any) => r.id);
const zeugmeisterIds: string[] = zeugResult.rows.map((r: any) => r.id);
for (const item of equipmentResult.rows) {
const targetUsers: string[] = item.motorisiert ? fahrmeisterIds : zeugmeisterIds;
if (targetUsers.length === 0) continue;
const isBroken = ['beschaedigt', 'ausser_dienst'].includes(item.status);
const pruefungTage = item.pruefung_tage != null ? parseInt(item.pruefung_tage, 10) : null;
const isOverdueInspection = pruefungTage !== null && pruefungTage < 0;
for (const userId of targetUsers) {
if (isBroken) {
await notificationService.createNotification({
user_id: userId,
typ: 'ausruestung_status',
titel: `Ausrüstung nicht einsatzbereit`,
nachricht: `${item.bezeichnung} hat den Status "${item.status}" und ist nicht einsatzbereit.`,
schwere: 'fehler',
link: `/ausruestung/${item.id}`,
quell_id: `ausruestung-status-${item.id}`,
quell_typ: 'ausruestung_status',
});
}
if (isOverdueInspection) {
const tage = Math.abs(pruefungTage!);
await notificationService.createNotification({
user_id: userId,
typ: 'ausruestung_pruefung',
titel: `Ausrüstungsprüfung überfällig`,
nachricht: `Die Prüfung von ${item.bezeichnung} ist seit ${tage} Tagen überfällig.`,
schwere: 'fehler',
link: `/ausruestung/${item.id}`,
quell_id: `ausruestung-pruefung-${item.id}`,
quell_typ: 'ausruestung_pruefung',
});
}
}
}
} catch (error) {
logger.error('NotificationGenerationJob: generateEquipmentNotifications failed', { error });
}
}
// ---------------------------------------------------------------------------
// Job lifecycle
// ---------------------------------------------------------------------------
export function startNotificationJob(): void {
if (jobInterval !== null) {
logger.warn('Notification generation job already running — skipping duplicate start');
return;
}
// Run once on startup, then repeat.
runNotificationGeneration();
jobInterval = setInterval(() => {
runNotificationGeneration();
}, INTERVAL_MS);
logger.info('Notification generation job scheduled (setInterval, 15min interval)');
}
export function stopNotificationJob(): void {
if (jobInterval !== null) {
clearInterval(jobInterval);
jobInterval = null;
logger.info('Notification generation job stopped');
}
}

View File

@@ -27,6 +27,7 @@ export interface AusruestungKategorie {
name: string;
kurzname: string;
sortierung: number;
motorisiert: boolean;
}
// ── Core Entity ───────────────────────────────────────────────────────────────
@@ -76,6 +77,7 @@ export interface AusruestungListItem {
updated_at: Date;
kategorie_name: string;
kategorie_kurzname: string;
kategorie_motorisiert: boolean;
fahrzeug_bezeichnung: string | null;
fahrzeug_kurzname: string | null;
pruefung_tage_bis_faelligkeit: number | null;

View File

@@ -11,6 +11,7 @@ export interface VeranstaltungKategorie {
farbe?: string | null;
icon?: string | null;
zielgruppen: string[];
alle_gruppen: boolean;
erstellt_von?: string | null;
erstellt_am: Date;
aktualisiert_am: Date;
@@ -91,6 +92,7 @@ export const CreateKategorieSchema = z.object({
.optional(),
icon: z.string().max(100).optional(),
zielgruppen: z.array(z.string()).optional(),
alle_gruppen: z.boolean().optional(),
});
export type CreateKategorieData = z.infer<typeof CreateKategorieSchema>;

View File

@@ -0,0 +1,31 @@
// =============================================================================
// Notification — Domain Model
// =============================================================================
export type NotificationSchwere = 'info' | 'warnung' | 'fehler';
export interface Notification {
id: string;
user_id: string;
typ: string;
titel: string;
nachricht: string;
schwere: NotificationSchwere;
gelesen: boolean;
gelesen_am: Date | null;
link: string | null;
quell_id: string | null;
quell_typ: string | null;
erstellt_am: Date;
}
export interface CreateNotificationData {
user_id: string;
typ: string;
titel: string;
nachricht: string;
schwere?: NotificationSchwere;
link?: string;
quell_id?: string;
quell_typ?: string;
}

View File

@@ -4,7 +4,7 @@ import { authenticate } from '../middleware/auth.middleware';
import { requireGroups } from '../middleware/rbac.middleware';
const ADMIN_GROUPS = ['dashboard_admin'];
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister'];
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_zeugmeister'];
const router = Router();

View File

@@ -0,0 +1,13 @@
import { Router } from 'express';
import notificationController from '../controllers/notification.controller';
import { authenticate } from '../middleware/auth.middleware';
const router = Router();
// All routes require authentication; users only see their own notifications.
router.get('/', authenticate, notificationController.getNotifications.bind(notificationController));
router.get('/count', authenticate, notificationController.getUnreadCount.bind(notificationController));
router.patch('/:id/read', authenticate, notificationController.markAsRead.bind(notificationController));
router.post('/mark-all-read', authenticate, notificationController.markAllRead.bind(notificationController));
export default router;

View File

@@ -3,6 +3,7 @@ import environment from './config/environment';
import logger from './utils/logger';
import { testConnection, closePool, runMigrations } from './config/database';
import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job';
import { startNotificationJob, stopNotificationJob } from './jobs/notification-generation.job';
const startServer = async (): Promise<void> => {
try {
@@ -20,6 +21,9 @@ const startServer = async (): Promise<void> => {
// Start the GDPR IP anonymisation job
startAuditCleanupJob();
// Start the notification generation job
startNotificationJob();
// Start the server
const server = app.listen(environment.port, () => {
logger.info('Server started successfully', {
@@ -35,6 +39,7 @@ const startServer = async (): Promise<void> => {
// Stop scheduled jobs first
stopAuditCleanupJob();
stopNotificationJob();
server.close(async () => {
logger.info('HTTP server closed');

View File

@@ -118,6 +118,19 @@ class EquipmentService {
}
}
async getCategoryById(id: string): Promise<AusruestungKategorie | null> {
try {
const result = await pool.query(
`SELECT * FROM ausruestung_kategorien WHERE id = $1`,
[id]
);
return result.rows.length > 0 ? (result.rows[0] as AusruestungKategorie) : null;
} catch (error) {
logger.error('EquipmentService.getCategoryById failed', { error, id });
throw new Error('Failed to fetch equipment category');
}
}
// =========================================================================
// CRUD
// =========================================================================

View File

@@ -110,7 +110,7 @@ class EventsService {
/** Returns all event categories ordered by name. */
async getKategorien(): Promise<VeranstaltungKategorie[]> {
const result = await pool.query(`
SELECT id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am
SELECT id, name, beschreibung, farbe, icon, zielgruppen, alle_gruppen, erstellt_von, erstellt_am, aktualisiert_am
FROM veranstaltung_kategorien
ORDER BY name ASC
`);
@@ -121,6 +121,7 @@ class EventsService {
farbe: row.farbe ?? null,
icon: row.icon ?? null,
zielgruppen: row.zielgruppen ?? [],
alle_gruppen: row.alle_gruppen ?? false,
erstellt_von: row.erstellt_von ?? null,
erstellt_am: new Date(row.erstellt_am),
aktualisiert_am: new Date(row.aktualisiert_am),
@@ -130,10 +131,10 @@ class EventsService {
/** Creates a new event category. */
async createKategorie(data: CreateKategorieData, userId: string): Promise<VeranstaltungKategorie> {
const result = await pool.query(
`INSERT INTO veranstaltung_kategorien (name, beschreibung, farbe, icon, zielgruppen, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am`,
[data.name, data.beschreibung ?? null, data.farbe ?? null, data.icon ?? null, data.zielgruppen ?? [], userId]
`INSERT INTO veranstaltung_kategorien (name, beschreibung, farbe, icon, zielgruppen, alle_gruppen, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, name, beschreibung, farbe, icon, zielgruppen, alle_gruppen, erstellt_von, erstellt_am, aktualisiert_am`,
[data.name, data.beschreibung ?? null, data.farbe ?? null, data.icon ?? null, data.zielgruppen ?? [], data.alle_gruppen ?? false, userId]
);
const row = result.rows[0];
return {
@@ -143,6 +144,7 @@ class EventsService {
farbe: row.farbe ?? null,
icon: row.icon ?? null,
zielgruppen: row.zielgruppen ?? [],
alle_gruppen: row.alle_gruppen ?? false,
erstellt_von: row.erstellt_von ?? null,
erstellt_am: new Date(row.erstellt_am),
aktualisiert_am: new Date(row.aktualisiert_am),
@@ -160,11 +162,12 @@ class EventsService {
if (data.farbe !== undefined) { fields.push(`farbe = $${idx++}`); values.push(data.farbe); }
if (data.icon !== undefined) { fields.push(`icon = $${idx++}`); values.push(data.icon); }
if (data.zielgruppen !== undefined) { fields.push(`zielgruppen = $${idx++}`); values.push(data.zielgruppen); }
if (data.alle_gruppen !== undefined) { fields.push(`alle_gruppen = $${idx++}`); values.push(data.alle_gruppen); }
if (fields.length === 0) {
// Nothing to update — return the existing record
const existing = await pool.query(
`SELECT id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am
`SELECT id, name, beschreibung, farbe, icon, zielgruppen, alle_gruppen, erstellt_von, erstellt_am, aktualisiert_am
FROM veranstaltung_kategorien WHERE id = $1`,
[id]
);
@@ -173,6 +176,7 @@ class EventsService {
return {
id: row.id, name: row.name, beschreibung: row.beschreibung ?? null,
farbe: row.farbe ?? null, icon: row.icon ?? null, zielgruppen: row.zielgruppen ?? [],
alle_gruppen: row.alle_gruppen ?? false,
erstellt_von: row.erstellt_von ?? null,
erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am),
};
@@ -184,7 +188,7 @@ class EventsService {
const result = await pool.query(
`UPDATE veranstaltung_kategorien SET ${fields.join(', ')}
WHERE id = $${idx}
RETURNING id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am`,
RETURNING id, name, beschreibung, farbe, icon, zielgruppen, alle_gruppen, erstellt_von, erstellt_am, aktualisiert_am`,
values
);
if (result.rows.length === 0) return null;
@@ -192,6 +196,7 @@ class EventsService {
return {
id: row.id, name: row.name, beschreibung: row.beschreibung ?? null,
farbe: row.farbe ?? null, icon: row.icon ?? null, zielgruppen: row.zielgruppen ?? [],
alle_gruppen: row.alle_gruppen ?? false,
erstellt_von: row.erstellt_von ?? null,
erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am),
};

View File

@@ -0,0 +1,131 @@
// =============================================================================
// Notification Service
// =============================================================================
import pool from '../config/database';
import logger from '../utils/logger';
import { Notification, CreateNotificationData } from '../models/notification.model';
function rowToNotification(row: any): Notification {
return {
id: row.id,
user_id: row.user_id,
typ: row.typ,
titel: row.titel,
nachricht: row.nachricht,
schwere: row.schwere,
gelesen: row.gelesen,
gelesen_am: row.gelesen_am ? new Date(row.gelesen_am) : null,
link: row.link ?? null,
quell_id: row.quell_id ?? null,
quell_typ: row.quell_typ ?? null,
erstellt_am: new Date(row.erstellt_am),
};
}
class NotificationService {
/** Returns all notifications for a user (newest first, max 100). */
async getByUser(userId: string): Promise<Notification[]> {
try {
const result = await pool.query(
`SELECT * FROM notifications WHERE user_id = $1 ORDER BY erstellt_am DESC LIMIT 100`,
[userId]
);
return result.rows.map(rowToNotification);
} catch (error) {
logger.error('NotificationService.getByUser failed', { error, userId });
throw new Error('Notifications konnten nicht geladen werden');
}
}
/** Returns the count of unread notifications for a user. */
async getUnreadCount(userId: string): Promise<number> {
try {
const result = await pool.query(
`SELECT COUNT(*) AS cnt FROM notifications WHERE user_id = $1 AND gelesen = FALSE`,
[userId]
);
return parseInt(result.rows[0].cnt, 10);
} catch (error) {
logger.error('NotificationService.getUnreadCount failed', { error, userId });
throw new Error('Ungelesene Notifications konnten nicht gezählt werden');
}
}
/** Marks a single notification as read. Returns false if not found or not owned by user. */
async markAsRead(id: string, userId: string): Promise<boolean> {
try {
const result = await pool.query(
`UPDATE notifications
SET gelesen = TRUE, gelesen_am = NOW()
WHERE id = $1 AND user_id = $2 AND gelesen = FALSE
RETURNING id`,
[id, userId]
);
return (result.rowCount ?? 0) > 0;
} catch (error) {
logger.error('NotificationService.markAsRead failed', { error, id, userId });
throw new Error('Notification konnte nicht als gelesen markiert werden');
}
}
/** Marks all notifications as read for a user. */
async markAllRead(userId: string): Promise<void> {
try {
await pool.query(
`UPDATE notifications SET gelesen = TRUE, gelesen_am = NOW()
WHERE user_id = $1 AND gelesen = FALSE`,
[userId]
);
} catch (error) {
logger.error('NotificationService.markAllRead failed', { error, userId });
throw new Error('Notifications konnten nicht als gelesen markiert werden');
}
}
/**
* Creates a notification. If quell_typ + quell_id are provided the insert is
* silently ignored when an identical unread notification already exists
* (dedup via unique index).
*/
async createNotification(data: CreateNotificationData): Promise<void> {
try {
await pool.query(
`INSERT INTO notifications
(user_id, typ, titel, nachricht, schwere, link, quell_id, quell_typ)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (user_id, quell_typ, quell_id) WHERE NOT gelesen AND quell_typ IS NOT NULL AND quell_id IS NOT NULL
DO NOTHING`,
[
data.user_id,
data.typ,
data.titel,
data.nachricht,
data.schwere ?? 'info',
data.link ?? null,
data.quell_id ?? null,
data.quell_typ ?? null,
]
);
} catch (error) {
logger.error('NotificationService.createNotification failed', { error });
// Non-fatal — don't propagate to callers
}
}
/** Deletes read notifications older than 90 days for all users. */
async deleteOldRead(): Promise<void> {
try {
const result = await pool.query(
`DELETE FROM notifications WHERE gelesen = TRUE AND gelesen_am < NOW() - INTERVAL '90 days'`
);
if ((result.rowCount ?? 0) > 0) {
logger.info(`NotificationService.deleteOldRead: removed ${result.rowCount} old notifications`);
}
} catch (error) {
logger.error('NotificationService.deleteOldRead failed', { error });
}
}
}
export default new NotificationService();

View File

@@ -1,139 +1,52 @@
import React from 'react';
import {
Card,
CardContent,
Avatar,
Typography,
Box,
Chip,
} from '@mui/material';
import { Avatar, Box, Paper, Typography } from '@mui/material';
import { User } from '../../types/auth.types';
interface UserProfileProps {
user: User;
}
const UserProfile: React.FC<UserProfileProps> = ({ user }) => {
// Get first letter of name for avatar
const getInitials = (name: string): string => {
return name.charAt(0).toUpperCase();
};
function getGreeting(): string {
const h = new Date().getHours();
if (h >= 5 && h <= 10) return 'Guten Morgen';
if (h >= 11 && h <= 13) return 'Mahlzeit';
if (h >= 14 && h <= 16) return 'Guten Nachmittag';
if (h >= 17 && h <= 21) return 'Guten Abend';
return 'Gute Nacht';
}
// Format date (placeholder until we have actual dates)
const formatDate = (date?: string): string => {
if (!date) return 'Nicht verfügbar';
return new Date(date).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
};
const UserProfile: React.FC<UserProfileProps> = ({ user }) => {
const firstName = user.given_name || user.name.split(' ')[0];
const initials = (user.given_name?.[0] || '') + (user.family_name?.[0] || '') || user.name?.[0] || '?';
return (
<Card
<Paper
elevation={0}
sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
borderRadius: 2,
px: 3,
py: 1.5,
}}
>
<CardContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: { xs: 'column', sm: 'row' },
gap: 3,
}}
>
{/* Avatar */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Avatar
sx={{
width: 80,
height: 80,
width: 40,
height: 40,
bgcolor: 'rgba(255,255,255,0.2)',
fontSize: '2rem',
fontSize: '1rem',
fontWeight: 'bold',
}}
>
{getInitials(user.name)}
{initials.toUpperCase()}
</Avatar>
{/* User Info */}
<Box sx={{ flex: 1, textAlign: { xs: 'center', sm: 'left' } }}>
<Typography variant="h5" component="div" gutterBottom>
Willkommen zurück, {user.given_name || user.name.split(' ')[0]}!
</Typography>
<Typography variant="body2" sx={{ opacity: 0.75, mb: 0.5 }}>
{user.name}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
{user.email}
</Typography>
{user.preferred_username && (
<Typography variant="body2" sx={{ opacity: 0.9 }}>
@{user.preferred_username}
</Typography>
)}
<Box
sx={{
display: 'flex',
gap: 1,
mt: 2,
flexWrap: 'wrap',
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<Chip
label="Aktiv"
size="small"
sx={{
bgcolor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
}}
/>
{user.groups && user.groups.length > 0 && (
<Chip
label={`${user.groups.length} Gruppe${user.groups.length > 1 ? 'n' : ''}`}
size="small"
sx={{
bgcolor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
}}
/>
)}
</Box>
</Box>
{/* Additional Info */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
textAlign: { xs: 'center', sm: 'right' },
}}
>
<Box>
<Typography variant="caption" sx={{ opacity: 0.8 }}>
Letzter Login
</Typography>
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
Heute
<Typography variant="h6" sx={{ fontWeight: 500 }}>
{getGreeting()}, {firstName}!
</Typography>
</Box>
<Box>
<Typography variant="caption" sx={{ opacity: 0.8 }}>
Mitglied seit
</Typography>
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
{formatDate()}
</Typography>
</Box>
</Box>
</Box>
</CardContent>
</Card>
</Paper>
);
};

View File

@@ -1,7 +1,6 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import {
AppBar,
Badge,
Toolbar,
Typography,
IconButton,
@@ -21,7 +20,7 @@ import {
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
import { useNavigate } from 'react-router-dom';
import { atemschutzApi } from '../../services/atemschutz';
import NotificationBell from './NotificationBell';
interface HeaderProps {
onMenuClick: () => void;
@@ -31,22 +30,6 @@ function Header({ onMenuClick }: HeaderProps) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [warningCount, setWarningCount] = useState(0);
// Fetch personal warning count for badge
useEffect(() => {
if (!user) return;
atemschutzApi.getMyStatus()
.then((record) => {
if (!record) return;
let count = 0;
const THRESHOLD = 60;
if (record.untersuchung_tage_rest !== null && record.untersuchung_tage_rest <= THRESHOLD) count++;
if (record.leistungstest_tage_rest !== null && record.leistungstest_tage_rest <= THRESHOLD) count++;
setWarningCount(count);
})
.catch(() => { /* non-critical */ });
}, [user]);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
@@ -103,18 +86,15 @@ function Header({ onMenuClick }: HeaderProps) {
{user && (
<>
<NotificationBell />
<IconButton
onClick={handleMenuOpen}
size="small"
aria-label="Benutzerkonto"
aria-controls="user-menu"
aria-haspopup="true"
>
<Badge
badgeContent={warningCount}
color="error"
overlap="circular"
invisible={warningCount === 0}
sx={{ ml: 1 }}
>
<Avatar
sx={{
@@ -126,7 +106,6 @@ function Header({ onMenuClick }: HeaderProps) {
>
{getInitials()}
</Avatar>
</Badge>
</IconButton>
<Menu
@@ -154,11 +133,6 @@ function Header({ onMenuClick }: HeaderProps) {
<Typography variant="body2" color="text.secondary">
{user.email}
</Typography>
{warningCount > 0 && (
<Typography variant="caption" color="error.main" sx={{ display: 'block', mt: 0.5 }}>
{warningCount} persönliche{warningCount !== 1 ? ' Warnungen' : ' Warnung'}
</Typography>
)}
</Box>
<Divider />
<MenuItem onClick={handleProfile}>

View File

@@ -0,0 +1,225 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Badge,
Box,
Button,
CircularProgress,
Divider,
IconButton,
List,
ListItem,
ListItemButton,
ListItemText,
Popover,
Tooltip,
Typography,
} from '@mui/material';
import {
Notifications as BellIcon,
NotificationsNone as BellEmptyIcon,
Circle as CircleIcon,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { notificationsApi } from '../../services/notifications';
import type { Notification, NotificationSchwere } from '../../types/notification.types';
const POLL_INTERVAL_MS = 60_000; // 60 seconds
function schwerebColor(schwere: NotificationSchwere): 'error' | 'warning' | 'info' {
if (schwere === 'fehler') return 'error';
if (schwere === 'warnung') return 'warning';
return 'info';
}
function formatRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 2) return 'Gerade eben';
if (minutes < 60) return `vor ${minutes} Min.`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `vor ${hours} Std.`;
const days = Math.floor(hours / 24);
return `vor ${days} Tag${days !== 1 ? 'en' : ''}`;
}
const NotificationBell: React.FC = () => {
const navigate = useNavigate();
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchUnreadCount = useCallback(async () => {
try {
const count = await notificationsApi.getUnreadCount();
setUnreadCount(count);
} catch {
// non-critical
}
}, []);
// Poll unread count every 60 seconds
useEffect(() => {
fetchUnreadCount();
pollTimerRef.current = setInterval(fetchUnreadCount, POLL_INTERVAL_MS);
return () => {
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
};
}, [fetchUnreadCount]);
const handleOpen = async (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
setLoadingList(true);
try {
const data = await notificationsApi.getNotifications();
setNotifications(data);
// Refresh count after loading full list
const count = await notificationsApi.getUnreadCount();
setUnreadCount(count);
} catch {
// non-critical
} finally {
setLoadingList(false);
}
};
const handleClose = () => {
setAnchorEl(null);
};
const handleClickNotification = async (n: Notification) => {
if (!n.gelesen) {
try {
await notificationsApi.markRead(n.id);
setNotifications((prev) =>
prev.map((item) => item.id === n.id ? { ...item, gelesen: true } : item)
);
setUnreadCount((c) => Math.max(0, c - 1));
} catch {
// non-critical
}
}
handleClose();
if (n.link) {
navigate(n.link);
}
};
const handleMarkAllRead = async () => {
try {
await notificationsApi.markAllRead();
setNotifications((prev) => prev.map((n) => ({ ...n, gelesen: true })));
setUnreadCount(0);
} catch {
// non-critical
}
};
const open = Boolean(anchorEl);
const hasUnread = unreadCount > 0;
return (
<>
<Tooltip title="Benachrichtigungen">
<IconButton
color="inherit"
onClick={handleOpen}
aria-label="Benachrichtigungen öffnen"
size="small"
>
<Badge badgeContent={unreadCount} color="error" invisible={!hasUnread}>
{hasUnread ? <BellIcon /> : <BellEmptyIcon />}
</Badge>
</IconButton>
</Tooltip>
<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
PaperProps={{ sx: { width: 360, maxHeight: 500, display: 'flex', flexDirection: 'column' } }}
>
{/* Header */}
<Box sx={{ px: 2, py: 1.5, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="subtitle1" fontWeight={600}>
Benachrichtigungen
</Typography>
{unreadCount > 0 && (
<Button size="small" onClick={handleMarkAllRead} sx={{ fontSize: '0.75rem' }}>
Alle als gelesen markieren
</Button>
)}
</Box>
<Divider />
{/* Body */}
<Box sx={{ overflowY: 'auto', flexGrow: 1 }}>
{loadingList ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
) : notifications.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<BellEmptyIcon sx={{ fontSize: 40, color: 'text.disabled', mb: 1 }} />
<Typography variant="body2" color="text.secondary">
Keine Benachrichtigungen
</Typography>
</Box>
) : (
<List disablePadding>
{notifications.map((n, idx) => (
<React.Fragment key={n.id}>
{idx > 0 && <Divider component="li" />}
<ListItem disablePadding>
<ListItemButton
onClick={() => handleClickNotification(n)}
sx={{
py: 1.5,
px: 2,
bgcolor: n.gelesen ? 'transparent' : 'action.hover',
alignItems: 'flex-start',
gap: 1,
}}
>
<CircleIcon
sx={{
fontSize: 8,
mt: 0.75,
flexShrink: 0,
color: n.gelesen ? 'transparent' : `${schwerebColor(n.schwere)}.main`,
}}
/>
<ListItemText
primary={
<Typography variant="body2" fontWeight={n.gelesen ? 400 : 600}>
{n.titel}
</Typography>
}
secondary={
<Box>
<Typography variant="caption" color="text.secondary" display="block">
{n.nachricht}
</Typography>
<Typography variant="caption" color="text.disabled">
{formatRelative(n.erstellt_am)}
</Typography>
</Box>
}
disableTypography
/>
</ListItemButton>
</ListItem>
</React.Fragment>
))}
</List>
)}
</Box>
</Popover>
</>
);
};
export default NotificationBell;

View File

@@ -1,13 +1,27 @@
import { useAuth } from '../contexts/AuthContext';
import { AusruestungKategorie } from '../types/equipment.types';
export function usePermissions() {
const { user } = useAuth();
const groups = user?.groups ?? [];
const isAdmin = groups.includes('dashboard_admin');
const isFahrmeister = groups.includes('dashboard_fahrmeister');
const isZeugmeister = groups.includes('dashboard_zeugmeister');
return {
isAdmin: groups.includes('dashboard_admin'),
canChangeStatus: groups.includes('dashboard_admin') || groups.includes('dashboard_fahrmeister'),
canManageEquipment: groups.includes('dashboard_admin') || groups.includes('dashboard_fahrmeister'),
isAdmin,
isFahrmeister,
isZeugmeister,
canChangeStatus: isAdmin || isFahrmeister || isZeugmeister,
canManageEquipment: isAdmin || isFahrmeister || isZeugmeister,
canManageMotorizedEquipment: isAdmin || isFahrmeister,
canManageNonMotorizedEquipment: isAdmin || isZeugmeister,
canManageCategory: (kategorie: AusruestungKategorie | null | undefined): boolean => {
if (isAdmin) return true;
if (!kategorie) return false;
return kategorie.motorisiert ? isFahrmeister : isZeugmeister;
},
groups,
};
}

View File

@@ -563,7 +563,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
function AusruestungDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAdmin, canChangeStatus } = usePermissions();
const { isAdmin, canManageCategory } = usePermissions();
const notification = useNotification();
const [equipment, setEquipment] = useState<AusruestungDetail | null>(null);
@@ -630,6 +630,16 @@ function AusruestungDetailPage() {
equipment.pruefung_tage_bis_faelligkeit !== null &&
equipment.pruefung_tage_bis_faelligkeit < 0;
// Derive an inline category object so canManageCategory can do the motorisiert check
const equipmentKategorie = {
id: equipment.kategorie_id,
name: equipment.kategorie_name,
kurzname: equipment.kategorie_kurzname,
sortierung: 0,
motorisiert: equipment.kategorie_motorisiert,
};
const canWrite = canManageCategory(equipmentKategorie);
const subtitle = [
equipment.kategorie_name,
equipment.seriennummer ? `SN: ${equipment.seriennummer}` : null,
@@ -665,7 +675,7 @@ function AusruestungDetailPage() {
label={AusruestungStatusLabel[equipment.status]}
color={STATUS_CHIP_COLOR[equipment.status]}
/>
{canChangeStatus && (
{canWrite && (
<Tooltip title="Gerät bearbeiten">
<IconButton
size="small"
@@ -714,7 +724,7 @@ function AusruestungDetailPage() {
<UebersichtTab
equipment={equipment}
onStatusUpdated={fetchEquipment}
canChangeStatus={canChangeStatus}
canChangeStatus={canWrite}
/>
</TabPanel>
@@ -723,7 +733,7 @@ function AusruestungDetailPage() {
equipmentId={equipment.id}
wartungslog={equipment.wartungslog ?? []}
onAdded={fetchEquipment}
canWrite={canChangeStatus}
canWrite={canWrite}
/>
</TabPanel>

View File

@@ -82,11 +82,11 @@ function toDateInput(iso: string | null | undefined): string {
function AusruestungForm() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { canChangeStatus } = usePermissions();
const { canManageEquipment } = usePermissions();
const isEditMode = Boolean(id);
// -- Permission guard: only authorized users may create or edit equipment ----
if (!canChangeStatus) {
if (!canManageEquipment) {
return (
<DashboardLayout>
<Container maxWidth="lg">

View File

@@ -13,8 +13,6 @@ import UpcomingEventsWidget from '../components/dashboard/UpcomingEventsWidget';
import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard';
import EquipmentDashboardCard from '../components/equipment/EquipmentDashboardCard';
import VehicleDashboardCard from '../components/vehicles/VehicleDashboardCard';
import PersonalWarningsBanner from '../components/dashboard/PersonalWarningsBanner';
function Dashboard() {
const { user } = useAuth();
const canViewAtemschutz = user?.groups?.some(g =>
@@ -56,17 +54,6 @@ function Dashboard() {
</Box>
)}
{/* Personal Warnings Banner — full width, conditionally rendered */}
{user && (
<Box sx={{ gridColumn: '1 / -1' }}>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '150ms' }}>
<Box>
<PersonalWarningsBanner user={user} />
</Box>
</Fade>
</Box>
)}
{/* Vehicle Status Card */}
<Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '300ms' }}>

View File

@@ -837,6 +837,19 @@ function VeranstaltungFormDialog({
}, [open, editingEvent]);
const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => {
if (field === 'kategorie_id' && !editingEvent) {
// Auto-fill zielgruppen / alle_gruppen from the selected category (only for new events)
const kat = kategorien.find((k) => k.id === value);
if (kat) {
setForm((prev) => ({
...prev,
kategorie_id: value as string | null,
alle_gruppen: kat.alle_gruppen,
zielgruppen: kat.alle_gruppen ? [] : kat.zielgruppen,
}));
return;
}
}
setForm((prev) => ({ ...prev, [field]: value }));
};

View File

@@ -48,6 +48,7 @@ interface KategorieFormData {
beschreibung: string;
farbe: string;
icon: string;
alle_gruppen: boolean;
zielgruppen: string[];
}
@@ -56,6 +57,7 @@ const EMPTY_FORM: KategorieFormData = {
beschreibung: '',
farbe: '#1976d2',
icon: '',
alle_gruppen: false,
zielgruppen: [],
};
@@ -80,7 +82,8 @@ function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieD
beschreibung: editing.beschreibung ?? '',
farbe: editing.farbe,
icon: editing.icon ?? '',
zielgruppen: editing.zielgruppen ?? [],
alle_gruppen: editing.alle_gruppen ?? false,
zielgruppen: editing.alle_gruppen ? [] : (editing.zielgruppen ?? []),
});
} else {
setForm({ ...EMPTY_FORM });
@@ -112,7 +115,8 @@ function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieD
beschreibung: form.beschreibung.trim() || undefined,
farbe: form.farbe,
icon: form.icon.trim() || undefined,
zielgruppen: form.zielgruppen,
alle_gruppen: form.alle_gruppen,
zielgruppen: form.alle_gruppen ? [] : form.zielgruppen,
};
if (editing) {
await eventsApi.updateKategorie(editing.id, payload);
@@ -188,7 +192,22 @@ function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieD
placeholder="z.B. EmojiEvents"
helperText="Name eines MUI Material Icons"
/>
{/* Group checkboxes */}
{/* alle_gruppen toggle */}
<FormControlLabel
control={
<Checkbox
checked={form.alle_gruppen}
onChange={(e) => setForm((prev) => ({
...prev,
alle_gruppen: e.target.checked,
zielgruppen: e.target.checked ? [] : prev.zielgruppen,
}))}
size="small"
/>
}
label="Alle Mitglieder"
/>
{/* Group checkboxes — disabled when alle_gruppen is set */}
{groups.length > 0 && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
@@ -203,6 +222,7 @@ function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieD
checked={form.zielgruppen.includes(group.id)}
onChange={() => handleGroupToggle(group.id)}
size="small"
disabled={form.alle_gruppen}
/>
}
label={group.label}
@@ -435,7 +455,15 @@ export default function VeranstaltungKategorien() {
{/* Gruppen */}
<TableCell>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{(kat.zielgruppen ?? []).length === 0
{kat.alle_gruppen ? (
<Chip
label="Alle Mitglieder"
size="small"
color="primary"
variant="outlined"
sx={{ fontSize: '0.75rem' }}
/>
) : (kat.zielgruppen ?? []).length === 0
? <Typography variant="body2" color="text.secondary"></Typography>
: (kat.zielgruppen ?? []).map((gId) => {
const group = groups.find((g) => g.id === gId);

View File

@@ -610,6 +610,19 @@ function EventFormDialog({
}, [open, editingEvent]);
const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => {
if (field === 'kategorie_id' && !editingEvent) {
// Auto-fill zielgruppen / alle_gruppen from the selected category (only for new events)
const kat = kategorien.find((k) => k.id === value);
if (kat) {
setForm((prev) => ({
...prev,
kategorie_id: value as string | null,
alle_gruppen: kat.alle_gruppen,
zielgruppen: kat.alle_gruppen ? [] : kat.zielgruppen,
}));
return;
}
}
setForm((prev) => ({ ...prev, [field]: value }));
};

View File

@@ -0,0 +1,31 @@
import { api } from './api';
import type { Notification } from '../types/notification.types';
async function unwrap<T>(
promise: ReturnType<typeof api.get<{ success: boolean; data: T }>>
): Promise<T> {
const response = await promise;
if (response.data?.data === undefined || response.data?.data === null) {
throw new Error('Invalid API response');
}
return response.data.data;
}
export const notificationsApi = {
async getNotifications(): Promise<Notification[]> {
return unwrap(api.get<{ success: boolean; data: Notification[] }>('/api/notifications'));
},
async getUnreadCount(): Promise<number> {
const data = await unwrap(api.get<{ success: boolean; data: { count: number } }>('/api/notifications/count'));
return data.count;
},
async markRead(id: string): Promise<void> {
await api.patch(`/api/notifications/${id}/read`);
},
async markAllRead(): Promise<void> {
await api.post('/api/notifications/mark-all-read');
},
};

View File

@@ -25,6 +25,7 @@ export interface AusruestungKategorie {
name: string;
kurzname: string;
sortierung: number;
motorisiert: boolean;
}
// ── API Response Shapes ──────────────────────────────────────────────────────
@@ -35,6 +36,7 @@ export interface AusruestungListItem {
kategorie_id: string;
kategorie_name: string;
kategorie_kurzname: string;
kategorie_motorisiert: boolean;
seriennummer: string | null;
inventarnummer: string | null;
hersteller: string | null;

View File

@@ -16,6 +16,7 @@ export interface VeranstaltungKategorie {
farbe: string; // hex color e.g. '#1976d2'
icon?: string | null; // MUI icon name
zielgruppen: string[];
alle_gruppen: boolean;
erstellt_am: string;
aktualisiert_am: string;
}

View File

@@ -0,0 +1,20 @@
// ---------------------------------------------------------------------------
// Notification types — mirrors backend model
// ---------------------------------------------------------------------------
export type NotificationSchwere = 'info' | 'warnung' | 'fehler';
export interface Notification {
id: string;
user_id: string;
typ: string;
titel: string;
nachricht: string;
schwere: NotificationSchwere;
gelesen: boolean;
gelesen_am: string | null;
link: string | null;
quell_id: string | null;
quell_typ: string | null;
erstellt_am: string;
}