featuer change for calendar

This commit is contained in:
Matthias Hochmeister
2026-03-03 09:52:10 +01:00
parent 146f79cf00
commit d9af34b744
11 changed files with 294 additions and 174 deletions

View File

@@ -91,6 +91,14 @@ class AuthController {
await userService.updateLastLogin(user.id); await userService.updateLastLogin(user.id);
await userService.updateGroups(user.id, groups); 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 // Audit: returning user login
auditService.logAudit({ auditService.logAudit({
user_id: user.id, user_id: user.id,
@@ -160,10 +168,10 @@ class AuthController {
user: { user: {
id: user.id, id: user.id,
email: user.email, email: user.email,
name: user.name, name: userInfo.name || user.name,
preferredUsername: user.preferred_username, preferredUsername: userInfo.preferred_username || user.preferred_username,
givenName: user.given_name, givenName: userInfo.given_name || user.given_name,
familyName: user.family_name, familyName: userInfo.family_name || user.family_name,
profilePictureUrl: user.profile_picture_url, profilePictureUrl: user.profile_picture_url,
isActive: user.is_active, isActive: user.is_active,
groups, groups,

View File

@@ -437,15 +437,6 @@ class EventsService {
/** /**
* Generates an iCal feed for a given token. * 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. * Returns null if the token is invalid.
*/ */
async getIcalExport(token: string): Promise<string | null> { async getIcalExport(token: string): Promise<string | null> {
@@ -460,14 +451,24 @@ class EventsService {
if (tokenResult.rows.length === 0) return null; if (tokenResult.rows.length === 0) return null;
// Fetch public events: all future events + those that ended in the last 30 days const userId = tokenResult.rows[0].user_id;
// Only alle_gruppen=TRUE events — see NOTE above about group limitation
// 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( const eventsResult = await pool.query(
`SELECT v.id, v.titel, v.beschreibung, v.ort, v.datum_von, v.datum_bis, v.ganztaegig, v.abgesagt `SELECT v.id, v.titel, v.beschreibung, v.ort, v.datum_von, v.datum_bis, v.ganztaegig, v.abgesagt
FROM veranstaltungen v 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' 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(); const now = new Date();

View File

@@ -1,5 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
Alert,
AlertTitle,
Box, Box,
Card, Card,
CardContent, CardContent,
@@ -96,29 +98,39 @@ const AtemschutzDashboardCard: React.FC<AtemschutzDashboardCardProps> = ({
einsatzbereit einsatzbereit
</Typography> </Typography>
{/* Concerns list */} {/* Concerns list — using Alert components for consistent warning styling */}
{hasConcerns && ( {hasConcerns && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
{(stats.untersuchungAbgelaufen > 0 || stats.leistungstestAbgelaufen > 0) && (
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Abgelaufen</AlertTitle>
{stats.untersuchungAbgelaufen > 0 && ( {stats.untersuchungAbgelaufen > 0 && (
<Typography variant="body2" color="error.main"> <Typography variant="body2">
{stats.untersuchungAbgelaufen} Untersuchung{stats.untersuchungAbgelaufen !== 1 ? 'en' : ''} abgelaufen {stats.untersuchungAbgelaufen} Untersuchung{stats.untersuchungAbgelaufen !== 1 ? 'en' : ''} abgelaufen
</Typography> </Typography>
)} )}
{stats.leistungstestAbgelaufen > 0 && ( {stats.leistungstestAbgelaufen > 0 && (
<Typography variant="body2" color="error.main"> <Typography variant="body2">
{stats.leistungstestAbgelaufen} Leistungstest{stats.leistungstestAbgelaufen !== 1 ? 's' : ''} abgelaufen {stats.leistungstestAbgelaufen} Leistungstest{stats.leistungstestAbgelaufen !== 1 ? 's' : ''} abgelaufen
</Typography> </Typography>
)} )}
</Alert>
)}
{(stats.untersuchungBaldFaellig > 0 || stats.leistungstestBaldFaellig > 0) && (
<Alert severity="warning" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Bald fällig</AlertTitle>
{stats.untersuchungBaldFaellig > 0 && ( {stats.untersuchungBaldFaellig > 0 && (
<Typography variant="body2" color="warning.main"> <Typography variant="body2">
{stats.untersuchungBaldFaellig} Untersuchung{stats.untersuchungBaldFaellig !== 1 ? 'en' : ''} bald fällig {stats.untersuchungBaldFaellig} Untersuchung{stats.untersuchungBaldFaellig !== 1 ? 'en' : ''} bald fällig
</Typography> </Typography>
)} )}
{stats.leistungstestBaldFaellig > 0 && ( {stats.leistungstestBaldFaellig > 0 && (
<Typography variant="body2" color="warning.main"> <Typography variant="body2">
{stats.leistungstestBaldFaellig} Leistungstest{stats.leistungstestBaldFaellig !== 1 ? 's' : ''} bald fällig {stats.leistungstestBaldFaellig} Leistungstest{stats.leistungstestBaldFaellig !== 1 ? 's' : ''} bald fällig
</Typography> </Typography>
)} )}
</Alert>
)}
</Box> </Box>
)} )}

View File

@@ -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);

View File

@@ -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<BookstackCardProps> = ({ onClick }) => {
return (
<ServiceCard
title="Bookstack"
description="Dokumentation und Wiki"
icon={MenuBook}
status="disconnected"
onClick={onClick}
/>
);
};
export default BookstackCard;

View File

@@ -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<NextcloudCardProps> = ({ onClick }) => {
return (
<ServiceCard
title="Nextcloud"
description="Dateien und Dokumente"
icon={Cloud}
status="disconnected"
onClick={onClick}
/>
);
};
export default NextcloudCard;

View File

@@ -4,9 +4,11 @@ import {
AlertTitle, AlertTitle,
Box, Box,
CircularProgress, CircularProgress,
Divider,
Link, Link,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { NotificationsActive as NotificationsActiveIcon } from '@mui/icons-material';
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom';
import { atemschutzApi } from '../../services/atemschutz'; import { atemschutzApi } from '../../services/atemschutz';
import type { User } from '../../types/auth.types'; import type { User } from '../../types/auth.types';
@@ -24,6 +26,8 @@ interface PersonalWarning {
/** Negative = overdue, 0 = today, positive = days remaining. */ /** Negative = overdue, 0 = today, positive = days remaining. */
tageRest: number; tageRest: number;
label: string; label: string;
/** Short description of what the user should do */
action: string;
} }
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -31,19 +35,21 @@ interface PersonalWarning {
function buildWarnings(record: AtemschutzUebersicht): PersonalWarning[] { function buildWarnings(record: AtemschutzUebersicht): PersonalWarning[] {
const warnings: 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({ warnings.push({
key: 'untersuchung', key: 'untersuchung',
tageRest: record.untersuchung_tage_rest, tageRest: record.untersuchung_tage_rest,
label: 'Atemschutz-Untersuchung', 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({ warnings.push({
key: 'leistungstest', key: 'leistungstest',
tageRest: record.leistungstest_tage_rest, tageRest: record.leistungstest_tage_rest,
label: 'Leistungstest', label: 'Leistungstest',
action: 'Atemschutzwart kontaktieren',
}); });
} }
@@ -63,9 +69,11 @@ function tageText(tage: number): string {
interface PersonalWarningsBannerProps { interface PersonalWarningsBannerProps {
user: User; user: User;
/** Called with the number of active warnings (for badge display) */
onWarningCount?: (count: number) => void;
} }
const PersonalWarningsBanner: React.FC<PersonalWarningsBannerProps> = ({ user: _user }) => { const PersonalWarningsBanner: React.FC<PersonalWarningsBannerProps> = ({ user: _user, onWarningCount }) => {
const [record, setRecord] = useState<AtemschutzUebersicht | null>(null); const [record, setRecord] = useState<AtemschutzUebersicht | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null); const [fetchError, setFetchError] = useState<string | null>(null);
@@ -112,13 +120,18 @@ const PersonalWarningsBanner: React.FC<PersonalWarningsBannerProps> = ({ user: _
// ── No atemschutz record for this user ───────────────────────────────────── // ── No atemschutz record for this user ─────────────────────────────────────
if (!record) return null; if (\!record) return null;
// ── Build warnings list ──────────────────────────────────────────────────── // ── Build warnings list ────────────────────────────────────────────────────
const warnings = buildWarnings(record); const warnings = buildWarnings(record);
if (warnings.length === 0) return null; if (warnings.length === 0) {
onWarningCount?.(0);
return null;
}
onWarningCount?.(warnings.length);
// ── Render ───────────────────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────────────────
@@ -127,7 +140,44 @@ const PersonalWarningsBanner: React.FC<PersonalWarningsBannerProps> = ({ user: _
const upcoming = warnings.filter((w) => w.tageRest >= 0); const upcoming = warnings.filter((w) => w.tageRest >= 0);
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> <Box
sx={{
border: '1px solid',
borderColor: overdue.length > 0 ? 'error.main' : 'warning.main',
borderRadius: 1,
overflow: 'hidden',
}}
>
{/* Banner header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 1,
bgcolor: overdue.length > 0 ? 'error.light' : 'warning.light',
}}
>
<NotificationsActiveIcon
fontSize="small"
sx={{ color: overdue.length > 0 ? 'error.dark' : 'warning.dark' }}
/>
<Typography
variant="subtitle2"
sx={{
fontWeight: 700,
color: overdue.length > 0 ? 'error.dark' : 'warning.dark',
}}
>
Persönliche Warnungen ({warnings.length})
</Typography>
</Box>
<Divider />
{/* Warning alerts */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, p: 1.5 }}>
{overdue.length > 0 && ( {overdue.length > 0 && (
<Alert severity="error" variant="outlined"> <Alert severity="error" variant="outlined">
<AlertTitle sx={{ fontWeight: 600 }}>Überfällig Handlungsbedarf</AlertTitle> <AlertTitle sx={{ fontWeight: 600 }}>Überfällig Handlungsbedarf</AlertTitle>
@@ -147,6 +197,9 @@ const PersonalWarningsBanner: React.FC<PersonalWarningsBannerProps> = ({ user: _
<Typography component="span" variant="body2"> <Typography component="span" variant="body2">
{tageText(w.tageRest)} {tageText(w.tageRest)}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
{w.action}
</Typography>
</Box> </Box>
))} ))}
</Box> </Box>
@@ -172,12 +225,16 @@ const PersonalWarningsBanner: React.FC<PersonalWarningsBannerProps> = ({ user: _
<Typography component="span" variant="body2"> <Typography component="span" variant="body2">
{tageText(w.tageRest)} {tageText(w.tageRest)}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
{w.action}
</Typography>
</Box> </Box>
))} ))}
</Box> </Box>
</Alert> </Alert>
)} )}
</Box> </Box>
</Box>
); );
}; };

View File

@@ -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<VikunjaCardProps> = ({ onClick }) => {
return (
<ServiceCard
title="Vikunja"
description="Aufgaben und Projekte"
icon={Assignment}
status="disconnected"
onClick={onClick}
/>
);
};
export default VikunjaCard;

View File

@@ -1,8 +1,5 @@
export { default as UserProfile } from './UserProfile'; export { default as UserProfile } from './UserProfile';
export { default as ServiceCard } from './ServiceCard'; 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 StatsCard } from './StatsCard';
export { default as ActivityFeed } from './ActivityFeed'; export { default as ActivityFeed } from './ActivityFeed';
export { default as DashboardLayout } from './DashboardLayout'; export { default as DashboardLayout } from './DashboardLayout';

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { import {
AppBar, AppBar,
Badge,
Toolbar, Toolbar,
Typography, Typography,
IconButton, IconButton,
@@ -20,6 +21,7 @@ import {
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { atemschutzApi } from '../../services/atemschutz';
interface HeaderProps { interface HeaderProps {
onMenuClick: () => void; onMenuClick: () => void;
@@ -29,6 +31,22 @@ function Header({ onMenuClick }: HeaderProps) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); 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>) => { const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
@@ -55,7 +73,7 @@ function Header({ onMenuClick }: HeaderProps) {
// Get initials for avatar // Get initials for avatar
const getInitials = () => { const getInitials = () => {
if (!user) return '?'; if (\!user) return '?';
const initials = (user.given_name?.[0] || '') + (user.family_name?.[0] || ''); const initials = (user.given_name?.[0] || '') + (user.family_name?.[0] || '');
return initials || user.name?.[0] || '?'; return initials || user.name?.[0] || '?';
}; };
@@ -70,7 +88,7 @@ function Header({ onMenuClick }: HeaderProps) {
<Toolbar> <Toolbar>
<IconButton <IconButton
color="inherit" color="inherit"
aria-label="Menü öffnen" aria-label="Men� �ffnen"
edge="start" edge="start"
onClick={onMenuClick} onClick={onMenuClick}
sx={{ mr: 2, display: { sm: 'none' } }} sx={{ mr: 2, display: { sm: 'none' } }}
@@ -91,6 +109,12 @@ function Header({ onMenuClick }: HeaderProps) {
aria-label="Benutzerkonto" aria-label="Benutzerkonto"
aria-controls="user-menu" aria-controls="user-menu"
aria-haspopup="true" aria-haspopup="true"
>
<Badge
badgeContent={warningCount}
color="error"
overlap="circular"
invisible={warningCount === 0}
> >
<Avatar <Avatar
sx={{ sx={{
@@ -102,6 +126,7 @@ function Header({ onMenuClick }: HeaderProps) {
> >
{getInitials()} {getInitials()}
</Avatar> </Avatar>
</Badge>
</IconButton> </IconButton>
<Menu <Menu
@@ -129,6 +154,11 @@ function Header({ onMenuClick }: HeaderProps) {
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{user.email} {user.email}
</Typography> </Typography>
{warningCount > 0 && (
<Typography variant="caption" color="error.main" sx={{ display: 'block', mt: 0.5 }}>
{warningCount} persönliche{warningCount !== 1 ? ' Warnungen' : ' Warnung'}
</Typography>
)}
</Box> </Box>
<Divider /> <Divider />
<MenuItem onClick={handleProfile}> <MenuItem onClick={handleProfile}>

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import {
Alert,
Container, Container,
Box, Box,
Typography, Typography,
@@ -116,11 +117,27 @@ function Dashboard() {
icon={DirectionsCar} icon={DirectionsCar}
color="success.main" color="success.main"
/> />
{vehicleWarnings.length > 0 && ( {vehicleWarnings.length > 0 && (() => {
<Typography variant="caption" color="warning.main" sx={{ mt: 0.5, display: 'block', textAlign: 'center' }}> const errorCount = vehicleWarnings.filter(w =>
{new Set(vehicleWarnings.map(w => w.fahrzeug_id)).size} Fzg. mit Ausrüstungsmangel w.status === 'beschaedigt' || w.status === 'ausser_dienst'
</Typography> ).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 (
<Alert
severity={severity}
variant="outlined"
sx={{ mt: 1, py: 0.5, '& .MuiAlert-message': { fontSize: '0.8rem' } }}
>
{vehicleCount} Fahrzeug{vehicleCount \!== 1 ? 'e' : ''} mit Ausrüstungsmangel
{errorCount > 0 && }
{warnCount > 0 && errorCount === 0 && }
</Alert>
);
})()}
</Box> </Box>
</Fade> </Fade>
)} )}