diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index 1c01666..387c338 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -91,6 +91,14 @@ class AuthController { await userService.updateLastLogin(user.id); await userService.updateGroups(user.id, groups); + // Refresh profile fields from Authentik on every login + await userService.updateUser(user.id, { + name: userInfo.name, + given_name: userInfo.given_name, + family_name: userInfo.family_name, + preferred_username: userInfo.preferred_username, + }); + // Audit: returning user login auditService.logAudit({ user_id: user.id, @@ -160,10 +168,10 @@ class AuthController { user: { id: user.id, email: user.email, - name: user.name, - preferredUsername: user.preferred_username, - givenName: user.given_name, - familyName: user.family_name, + name: userInfo.name || user.name, + preferredUsername: userInfo.preferred_username || user.preferred_username, + givenName: userInfo.given_name || user.given_name, + familyName: userInfo.family_name || user.family_name, profilePictureUrl: user.profile_picture_url, isActive: user.is_active, groups, diff --git a/backend/src/services/events.service.ts b/backend/src/services/events.service.ts index 2d25d68..b7f4358 100644 --- a/backend/src/services/events.service.ts +++ b/backend/src/services/events.service.ts @@ -437,15 +437,6 @@ class EventsService { /** * Generates an iCal feed for a given token. * - * NOTE — Group visibility limitation: - * Groups are issued by Authentik and embedded only in the short-lived JWT. - * They are NOT persisted in the database. For token-based iCal access we - * therefore cannot look up which Authentik groups a user belongs to. - * As a safe fallback this export includes only events where alle_gruppen=TRUE - * (i.e. events intended for everyone). Authenticated users who request the - * .ics directly via Bearer token already get group-filtered results through - * the normal API endpoints. - * * Returns null if the token is invalid. */ async getIcalExport(token: string): Promise { @@ -460,14 +451,24 @@ class EventsService { if (tokenResult.rows.length === 0) return null; - // Fetch public events: all future events + those that ended in the last 30 days - // Only alle_gruppen=TRUE events — see NOTE above about group limitation + const userId = tokenResult.rows[0].user_id; + + // Look up user's Authentik groups from DB for group-filtered event visibility + const userResult = await pool.query( + `SELECT authentik_groups FROM users WHERE id = $1`, + [userId] + ); + const userGroups: string[] = userResult.rows[0]?.authentik_groups ?? []; + + // Fetch events visible to this user: public events (alle_gruppen=TRUE) or events + // targeting the user's Authentik groups. Includes upcoming events + last 30 days. const eventsResult = await pool.query( `SELECT v.id, v.titel, v.beschreibung, v.ort, v.datum_von, v.datum_bis, v.ganztaegig, v.abgesagt FROM veranstaltungen v - WHERE v.alle_gruppen = TRUE + WHERE (v.alle_gruppen = TRUE OR v.zielgruppen && $1::text[]) AND v.datum_bis >= NOW() - INTERVAL '30 days' - ORDER BY v.datum_von ASC` + ORDER BY v.datum_von ASC`, + [userGroups] ); const now = new Date(); diff --git a/frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx b/frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx index 4e02a51..8fc8a9d 100644 --- a/frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx +++ b/frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx @@ -1,5 +1,7 @@ import React, { useEffect, useState } from 'react'; import { + Alert, + AlertTitle, Box, Card, CardContent, @@ -96,28 +98,38 @@ const AtemschutzDashboardCard: React.FC = ({ einsatzbereit - {/* Concerns list */} + {/* Concerns list — using Alert components for consistent warning styling */} {hasConcerns && ( - - {stats.untersuchungAbgelaufen > 0 && ( - - {stats.untersuchungAbgelaufen} Untersuchung{stats.untersuchungAbgelaufen !== 1 ? 'en' : ''} abgelaufen - + + {(stats.untersuchungAbgelaufen > 0 || stats.leistungstestAbgelaufen > 0) && ( + + Abgelaufen + {stats.untersuchungAbgelaufen > 0 && ( + + {stats.untersuchungAbgelaufen} Untersuchung{stats.untersuchungAbgelaufen !== 1 ? 'en' : ''} abgelaufen + + )} + {stats.leistungstestAbgelaufen > 0 && ( + + {stats.leistungstestAbgelaufen} Leistungstest{stats.leistungstestAbgelaufen !== 1 ? 's' : ''} abgelaufen + + )} + )} - {stats.leistungstestAbgelaufen > 0 && ( - - {stats.leistungstestAbgelaufen} Leistungstest{stats.leistungstestAbgelaufen !== 1 ? 's' : ''} abgelaufen - - )} - {stats.untersuchungBaldFaellig > 0 && ( - - {stats.untersuchungBaldFaellig} Untersuchung{stats.untersuchungBaldFaellig !== 1 ? 'en' : ''} bald fällig - - )} - {stats.leistungstestBaldFaellig > 0 && ( - - {stats.leistungstestBaldFaellig} Leistungstest{stats.leistungstestBaldFaellig !== 1 ? 's' : ''} bald fällig - + {(stats.untersuchungBaldFaellig > 0 || stats.leistungstestBaldFaellig > 0) && ( + + Bald fällig + {stats.untersuchungBaldFaellig > 0 && ( + + {stats.untersuchungBaldFaellig} Untersuchung{stats.untersuchungBaldFaellig !== 1 ? 'en' : ''} bald fällig + + )} + {stats.leistungstestBaldFaellig > 0 && ( + + {stats.leistungstestBaldFaellig} Leistungstest{stats.leistungstestBaldFaellig !== 1 ? 's' : ''} bald fällig + + )} + )} )} diff --git a/frontend/src/components/dashboard/.!60585!PersonalWarningsBanner.tsx b/frontend/src/components/dashboard/.!60585!PersonalWarningsBanner.tsx new file mode 100644 index 0000000..5ec031d --- /dev/null +++ b/frontend/src/components/dashboard/.!60585!PersonalWarningsBanner.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useState } from 'react'; +import { + Alert, + AlertTitle, + Box, + CircularProgress, + Divider, + Link, + Typography, +} from '@mui/material'; +import { NotificationsActive as NotificationsActiveIcon } from '@mui/icons-material'; +import { Link as RouterLink } from 'react-router-dom'; +import { atemschutzApi } from '../../services/atemschutz'; +import type { User } from '../../types/auth.types'; +import type { AtemschutzUebersicht } from '../../types/atemschutz.types'; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/** Show a warning banner if a deadline is within this many days. */ +const WARNING_THRESHOLD_DAYS = 60; + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface PersonalWarning { + key: string; + /** Negative = overdue, 0 = today, positive = days remaining. */ + tageRest: number; + label: string; + /** Short description of what the user should do */ + action: string; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function buildWarnings(record: AtemschutzUebersicht): PersonalWarning[] { + const warnings: PersonalWarning[] = []; + + if (record.untersuchung_tage_rest \!== null && record.untersuchung_tage_rest <= WARNING_THRESHOLD_DAYS) { + warnings.push({ + key: 'untersuchung', + tageRest: record.untersuchung_tage_rest, + label: 'Atemschutz-Untersuchung', + action: 'Termin beim Betriebsarzt vereinbaren', + }); + } + + if (record.leistungstest_tage_rest \!== null && record.leistungstest_tage_rest <= WARNING_THRESHOLD_DAYS) { + warnings.push({ + key: 'leistungstest', + tageRest: record.leistungstest_tage_rest, + label: 'Leistungstest', + action: 'Atemschutzwart kontaktieren', + }); + } + + return warnings; +} + +function tageText(tage: number): string { + if (tage < 0) { + const abs = Math.abs(tage); diff --git a/frontend/src/components/dashboard/BookstackCard.tsx b/frontend/src/components/dashboard/BookstackCard.tsx deleted file mode 100644 index f6cf279..0000000 --- a/frontend/src/components/dashboard/BookstackCard.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import { MenuBook } from '@mui/icons-material'; -import ServiceCard from './ServiceCard'; - -interface BookstackCardProps { - onClick?: () => void; -} - -const BookstackCard: React.FC = ({ onClick }) => { - return ( - - ); -}; - -export default BookstackCard; diff --git a/frontend/src/components/dashboard/NextcloudCard.tsx b/frontend/src/components/dashboard/NextcloudCard.tsx deleted file mode 100644 index 89c9c2d..0000000 --- a/frontend/src/components/dashboard/NextcloudCard.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import { Cloud } from '@mui/icons-material'; -import ServiceCard from './ServiceCard'; - -interface NextcloudCardProps { - onClick?: () => void; -} - -const NextcloudCard: React.FC = ({ onClick }) => { - return ( - - ); -}; - -export default NextcloudCard; diff --git a/frontend/src/components/dashboard/PersonalWarningsBanner.tsx b/frontend/src/components/dashboard/PersonalWarningsBanner.tsx index 17c9e26..0792096 100644 --- a/frontend/src/components/dashboard/PersonalWarningsBanner.tsx +++ b/frontend/src/components/dashboard/PersonalWarningsBanner.tsx @@ -4,9 +4,11 @@ import { AlertTitle, Box, CircularProgress, + Divider, Link, Typography, } from '@mui/material'; +import { NotificationsActive as NotificationsActiveIcon } from '@mui/icons-material'; import { Link as RouterLink } from 'react-router-dom'; import { atemschutzApi } from '../../services/atemschutz'; import type { User } from '../../types/auth.types'; @@ -24,6 +26,8 @@ interface PersonalWarning { /** Negative = overdue, 0 = today, positive = days remaining. */ tageRest: number; label: string; + /** Short description of what the user should do */ + action: string; } // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -31,19 +35,21 @@ interface PersonalWarning { function buildWarnings(record: AtemschutzUebersicht): PersonalWarning[] { const warnings: PersonalWarning[] = []; - if (record.untersuchung_tage_rest !== null && record.untersuchung_tage_rest <= WARNING_THRESHOLD_DAYS) { + if (record.untersuchung_tage_rest \!== null && record.untersuchung_tage_rest <= WARNING_THRESHOLD_DAYS) { warnings.push({ key: 'untersuchung', tageRest: record.untersuchung_tage_rest, label: 'Atemschutz-Untersuchung', + action: 'Termin beim Betriebsarzt vereinbaren', }); } - if (record.leistungstest_tage_rest !== null && record.leistungstest_tage_rest <= WARNING_THRESHOLD_DAYS) { + if (record.leistungstest_tage_rest \!== null && record.leistungstest_tage_rest <= WARNING_THRESHOLD_DAYS) { warnings.push({ key: 'leistungstest', tageRest: record.leistungstest_tage_rest, label: 'Leistungstest', + action: 'Atemschutzwart kontaktieren', }); } @@ -63,9 +69,11 @@ function tageText(tage: number): string { interface PersonalWarningsBannerProps { user: User; + /** Called with the number of active warnings (for badge display) */ + onWarningCount?: (count: number) => void; } -const PersonalWarningsBanner: React.FC = ({ user: _user }) => { +const PersonalWarningsBanner: React.FC = ({ user: _user, onWarningCount }) => { const [record, setRecord] = useState(null); const [loading, setLoading] = useState(true); const [fetchError, setFetchError] = useState(null); @@ -112,13 +120,18 @@ const PersonalWarningsBanner: React.FC = ({ user: _ // ── No atemschutz record for this user ───────────────────────────────────── - if (!record) return null; + if (\!record) return null; // ── Build warnings list ──────────────────────────────────────────────────── const warnings = buildWarnings(record); - if (warnings.length === 0) return null; + if (warnings.length === 0) { + onWarningCount?.(0); + return null; + } + + onWarningCount?.(warnings.length); // ── Render ───────────────────────────────────────────────────────────────── @@ -127,56 +140,100 @@ const PersonalWarningsBanner: React.FC = ({ user: _ const upcoming = warnings.filter((w) => w.tageRest >= 0); return ( - - {overdue.length > 0 && ( - - Überfällig — Handlungsbedarf - - {overdue.map((w) => ( - - - {w.label} - - {' — '} - - {tageText(w.tageRest)} - - - ))} - - - )} + 0 ? 'error.main' : 'warning.main', + borderRadius: 1, + overflow: 'hidden', + }} + > + {/* Banner header */} + 0 ? 'error.light' : 'warning.light', + }} + > + 0 ? 'error.dark' : 'warning.dark' }} + /> + 0 ? 'error.dark' : 'warning.dark', + }} + > + Persönliche Warnungen ({warnings.length}) + + - {upcoming.length > 0 && ( - - Frist läuft bald ab - - {upcoming.map((w) => ( - - - {w.label} - - {' — '} - - {tageText(w.tageRest)} - - - ))} - - - )} + + + {/* Warning alerts */} + + {overdue.length > 0 && ( + + Überfällig — Handlungsbedarf + + {overdue.map((w) => ( + + + {w.label} + + {' — '} + + {tageText(w.tageRest)} + + + → {w.action} + + + ))} + + + )} + + {upcoming.length > 0 && ( + + Frist läuft bald ab + + {upcoming.map((w) => ( + + + {w.label} + + {' — '} + + {tageText(w.tageRest)} + + + → {w.action} + + + ))} + + + )} + ); }; diff --git a/frontend/src/components/dashboard/VikunjaCard.tsx b/frontend/src/components/dashboard/VikunjaCard.tsx deleted file mode 100644 index 3373e3f..0000000 --- a/frontend/src/components/dashboard/VikunjaCard.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import { Assignment } from '@mui/icons-material'; -import ServiceCard from './ServiceCard'; - -interface VikunjaCardProps { - onClick?: () => void; -} - -const VikunjaCard: React.FC = ({ onClick }) => { - return ( - - ); -}; - -export default VikunjaCard; diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts index 6ba19ba..8e5fe28 100644 --- a/frontend/src/components/dashboard/index.ts +++ b/frontend/src/components/dashboard/index.ts @@ -1,8 +1,5 @@ export { default as UserProfile } from './UserProfile'; export { default as ServiceCard } from './ServiceCard'; -export { default as NextcloudCard } from './NextcloudCard'; -export { default as VikunjaCard } from './VikunjaCard'; -export { default as BookstackCard } from './BookstackCard'; export { default as StatsCard } from './StatsCard'; export { default as ActivityFeed } from './ActivityFeed'; export { default as DashboardLayout } from './DashboardLayout'; diff --git a/frontend/src/components/shared/Header.tsx b/frontend/src/components/shared/Header.tsx index 18d1d40..bf96bbb 100644 --- a/frontend/src/components/shared/Header.tsx +++ b/frontend/src/components/shared/Header.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { AppBar, + Badge, Toolbar, Typography, IconButton, @@ -20,6 +21,7 @@ import { } from '@mui/icons-material'; import { useAuth } from '../../contexts/AuthContext'; import { useNavigate } from 'react-router-dom'; +import { atemschutzApi } from '../../services/atemschutz'; interface HeaderProps { onMenuClick: () => void; @@ -29,6 +31,22 @@ function Header({ onMenuClick }: HeaderProps) { const { user, logout } = useAuth(); const navigate = useNavigate(); const [anchorEl, setAnchorEl] = useState(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) => { setAnchorEl(event.currentTarget); @@ -55,7 +73,7 @@ function Header({ onMenuClick }: HeaderProps) { // Get initials for avatar const getInitials = () => { - if (!user) return '?'; + if (\!user) return '?'; const initials = (user.given_name?.[0] || '') + (user.family_name?.[0] || ''); return initials || user.name?.[0] || '?'; }; @@ -70,7 +88,7 @@ function Header({ onMenuClick }: HeaderProps) { - - {getInitials()} - + + {getInitials()} + + {user.email} + {warningCount > 0 && ( + + {warningCount} persönliche{warningCount !== 1 ? ' Warnungen' : ' Warnung'} + + )} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 2889596..a1af8c7 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { + Alert, Container, Box, Typography, @@ -116,11 +117,27 @@ function Dashboard() { icon={DirectionsCar} color="success.main" /> - {vehicleWarnings.length > 0 && ( - - {new Set(vehicleWarnings.map(w => w.fahrzeug_id)).size} Fzg. mit Ausrüstungsmangel - - )} + {vehicleWarnings.length > 0 && (() => { + const errorCount = vehicleWarnings.filter(w => + w.status === 'beschaedigt' || w.status === 'ausser_dienst' + ).length; + const warnCount = vehicleWarnings.filter(w => + w.status === 'in_wartung' + ).length; + const vehicleCount = new Set(vehicleWarnings.map(w => w.fahrzeug_id)).size; + const severity = errorCount > 0 ? 'error' : 'warning'; + return ( + + {vehicleCount} Fahrzeug{vehicleCount \!== 1 ? 'e' : ''} mit Ausrüstungsmangel + {errorCount > 0 && } + {warnCount > 0 && errorCount === 0 && } + + ); + })()} )}