From 146f79cf00f82e097ce91401a5bf625c0f79a0e0 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Tue, 3 Mar 2026 08:57:32 +0100 Subject: [PATCH] featuer change for calendar --- .../src/controllers/atemschutz.controller.ts | 16 + backend/src/routes/atemschutz.routes.ts | 7 +- .../dashboard/PersonalWarningsBanner.tsx | 184 ++++++++++ .../dashboard/UpcomingEventsWidget.tsx | 319 ++++++++++++++++++ frontend/src/components/dashboard/index.ts | 2 + frontend/src/pages/Dashboard.tsx | 68 ++-- frontend/src/pages/Fahrzeuge.tsx | 56 ++- frontend/src/services/atemschutz.ts | 8 + frontend/src/services/auth.ts | 21 +- 9 files changed, 617 insertions(+), 64 deletions(-) create mode 100644 frontend/src/components/dashboard/PersonalWarningsBanner.tsx create mode 100644 frontend/src/components/dashboard/UpcomingEventsWidget.tsx diff --git a/backend/src/controllers/atemschutz.controller.ts b/backend/src/controllers/atemschutz.controller.ts index d349e24..e5c7a8b 100644 --- a/backend/src/controllers/atemschutz.controller.ts +++ b/backend/src/controllers/atemschutz.controller.ts @@ -116,6 +116,22 @@ class AtemschutzController { } } + async getMyStatus(req: Request, res: Response): Promise { + try { + const userId = getUserId(req); + const record = await atemschutzService.getByUserId(userId); + if (!record) { + // User has no atemschutz entry — not an error, just no data + res.status(200).json({ success: true, data: null }); + return; + } + res.status(200).json({ success: true, data: record }); + } catch (error) { + logger.error('Atemschutz getMyStatus error', { error }); + res.status(500).json({ success: false, message: 'Persönlicher Atemschutz-Status konnte nicht geladen werden' }); + } + } + async delete(req: Request, res: Response): Promise { try { const { id } = req.params as Record; diff --git a/backend/src/routes/atemschutz.routes.ts b/backend/src/routes/atemschutz.routes.ts index fbceeb7..96cdf22 100644 --- a/backend/src/routes/atemschutz.routes.ts +++ b/backend/src/routes/atemschutz.routes.ts @@ -10,9 +10,10 @@ const router = Router(); // ── Read-only (any authenticated user) ─────────────────────────────────────── -router.get('/', authenticate, atemschutzController.list.bind(atemschutzController)); -router.get('/stats', authenticate, atemschutzController.getStats.bind(atemschutzController)); -router.get('/:id', authenticate, atemschutzController.getOne.bind(atemschutzController)); +router.get('/', authenticate, atemschutzController.list.bind(atemschutzController)); +router.get('/stats', authenticate, atemschutzController.getStats.bind(atemschutzController)); +router.get('/my-status', authenticate, atemschutzController.getMyStatus.bind(atemschutzController)); +router.get('/:id', authenticate, atemschutzController.getOne.bind(atemschutzController)); // ── Write — admin + kommandant ─────────────────────────────────────────────── diff --git a/frontend/src/components/dashboard/PersonalWarningsBanner.tsx b/frontend/src/components/dashboard/PersonalWarningsBanner.tsx new file mode 100644 index 0000000..17c9e26 --- /dev/null +++ b/frontend/src/components/dashboard/PersonalWarningsBanner.tsx @@ -0,0 +1,184 @@ +import React, { useEffect, useState } from 'react'; +import { + Alert, + AlertTitle, + Box, + CircularProgress, + Link, + Typography, +} from '@mui/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; +} + +// ── 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', + }); + } + + if (record.leistungstest_tage_rest !== null && record.leistungstest_tage_rest <= WARNING_THRESHOLD_DAYS) { + warnings.push({ + key: 'leistungstest', + tageRest: record.leistungstest_tage_rest, + label: 'Leistungstest', + }); + } + + return warnings; +} + +function tageText(tage: number): string { + if (tage < 0) { + const abs = Math.abs(tage); + return `seit ${abs} Tag${abs === 1 ? '' : 'en'} überfällig`; + } + if (tage === 0) return 'heute fällig'; + return `fällig in ${tage} Tag${tage === 1 ? '' : 'en'}`; +} + +// ── Component ───────────────────────────────────────────────────────────────── + +interface PersonalWarningsBannerProps { + user: User; +} + +const PersonalWarningsBanner: React.FC = ({ user: _user }) => { + const [record, setRecord] = useState(null); + const [loading, setLoading] = useState(true); + const [fetchError, setFetchError] = useState(null); + + useEffect(() => { + let mounted = true; + + const fetchStatus = async () => { + try { + setLoading(true); + setFetchError(null); + const data = await atemschutzApi.getMyStatus(); + if (mounted) setRecord(data); + } catch { + if (mounted) setFetchError('Persönlicher Atemschutz-Status konnte nicht geladen werden.'); + } finally { + if (mounted) setLoading(false); + } + }; + + fetchStatus(); + return () => { mounted = false; }; + }, []); + + // ── Loading state ────────────────────────────────────────────────────────── + + if (loading) { + return ( + + + + Persönliche Fristen werden geprüft… + + + ); + } + + // ── Fetch error ──────────────────────────────────────────────────────────── + + if (fetchError) { + // Non-critical — silently swallow so the dashboard still loads cleanly. + return null; + } + + // ── No atemschutz record for this user ───────────────────────────────────── + + if (!record) return null; + + // ── Build warnings list ──────────────────────────────────────────────────── + + const warnings = buildWarnings(record); + + if (warnings.length === 0) return null; + + // ── Render ───────────────────────────────────────────────────────────────── + + // Split into overdue (error) and upcoming (warning) groups. + const overdue = warnings.filter((w) => w.tageRest < 0); + const upcoming = warnings.filter((w) => w.tageRest >= 0); + + return ( + + {overdue.length > 0 && ( + + Überfällig — Handlungsbedarf + + {overdue.map((w) => ( + + + {w.label} + + {' — '} + + {tageText(w.tageRest)} + + + ))} + + + )} + + {upcoming.length > 0 && ( + + Frist läuft bald ab + + {upcoming.map((w) => ( + + + {w.label} + + {' — '} + + {tageText(w.tageRest)} + + + ))} + + + )} + + ); +}; + +export default PersonalWarningsBanner; diff --git a/frontend/src/components/dashboard/UpcomingEventsWidget.tsx b/frontend/src/components/dashboard/UpcomingEventsWidget.tsx new file mode 100644 index 0000000..d8f848b --- /dev/null +++ b/frontend/src/components/dashboard/UpcomingEventsWidget.tsx @@ -0,0 +1,319 @@ +import React, { useEffect, useState } from 'react'; +import { + Box, + Card, + CardContent, + CircularProgress, + Chip, + Divider, + Link, + List, + ListItem, + Typography, +} from '@mui/material'; +import { CalendarMonth as CalendarMonthIcon } from '@mui/icons-material'; +import { Link as RouterLink } from 'react-router-dom'; +import { trainingApi } from '../../services/training'; +import { eventsApi } from '../../services/events'; +import type { UebungListItem, UebungTyp } from '../../types/training.types'; +import type { VeranstaltungListItem } from '../../types/events.types'; + +// --------------------------------------------------------------------------- +// Color map — matches TYP_DOT_COLOR in Kalender.tsx +// --------------------------------------------------------------------------- +const TYP_DOT_COLOR: Record = { + 'Übungsabend': '#1976d2', + 'Lehrgang': '#7b1fa2', + 'Sonderdienst': '#e65100', + 'Versammlung': '#616161', + 'Gemeinschaftsübung': '#00796b', + 'Sonstiges': '#9e9e9e', +}; + +/** Fallback color for Veranstaltung when no kategorie_farbe is present */ +const VERANSTALTUNG_DEFAULT_COLOR = '#c62828'; + +// --------------------------------------------------------------------------- +// Unified entry shape used internally +// --------------------------------------------------------------------------- +interface CalendarEntry { + id: string; + date: Date; + title: string; + /** Hex color for the type dot */ + color: string; + /** Human-readable label shown in the chip */ + typeLabel: string; + /** Whether the event is all-day (no time shown) */ + allDay: boolean; + /** Source: training or event */ + source: 'training' | 'event'; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const WEEKDAY_SHORT = ['So.', 'Mo.', 'Di.', 'Mi.', 'Do.', 'Fr.', 'Sa.']; + +function formatDateShort(d: Date): string { + const weekday = WEEKDAY_SHORT[d.getDay()]; + const day = String(d.getDate()).padStart(2, '0'); + const month = String(d.getMonth() + 1).padStart(2, '0'); + return `${weekday} ${day}.${month}.`; +} + +function formatTime(isoString: string): string { + const d = new Date(isoString); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')} Uhr`; +} + +function startOfToday(): Date { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return d; +} + +function mapTraining(item: UebungListItem): CalendarEntry { + return { + id: `training-${item.id}`, + date: new Date(item.datum_von), + title: item.titel, + color: TYP_DOT_COLOR[item.typ] ?? TYP_DOT_COLOR['Sonstiges'], + typeLabel: item.typ, + allDay: false, + source: 'training', + }; +} + +function mapVeranstaltung(item: VeranstaltungListItem): CalendarEntry { + return { + id: `event-${item.id}`, + date: new Date(item.datum_von), + title: item.titel, + color: item.kategorie_farbe ?? VERANSTALTUNG_DEFAULT_COLOR, + typeLabel: item.kategorie_name ?? 'Veranstaltung', + allDay: item.ganztaegig, + source: 'event', + }; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +const FETCH_LIMIT = 20; // fetch more than 5 so filtering from today leaves enough +const DISPLAY_LIMIT = 5; + +const UpcomingEventsWidget: React.FC = () => { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + + const fetchData = async () => { + try { + setLoading(true); + setError(null); + + const [trainingItems, eventItems] = await Promise.all([ + trainingApi.getUpcoming(FETCH_LIMIT), + eventsApi.getUpcoming(FETCH_LIMIT), + ]); + + if (!mounted) return; + + const today = startOfToday(); + + const combined: CalendarEntry[] = [ + ...trainingItems + .filter((t) => !t.abgesagt) + .map(mapTraining), + ...eventItems + .filter((e) => !e.abgesagt) + .map(mapVeranstaltung), + ] + .filter((e) => e.date >= today) + .sort((a, b) => a.date.getTime() - b.date.getTime()) + .slice(0, DISPLAY_LIMIT); + + setEntries(combined); + } catch { + if (mounted) setError('Termine konnten nicht geladen werden.'); + } finally { + if (mounted) setLoading(false); + } + }; + + fetchData(); + return () => { + mounted = false; + }; + }, []); + + // ── Loading state ───────────────────────────────────────────────────────── + if (loading) { + return ( + + + + + Termine werden geladen... + + + + ); + } + + // ── Error state ─────────────────────────────────────────────────────────── + if (error) { + return ( + + + + + Nächste Termine + + + {error} + + + + ); + } + + // ── Main render ─────────────────────────────────────────────────────────── + return ( + + + {/* Header */} + + + + Nächste Termine + + + + + + {/* Empty state */} + {entries.length === 0 ? ( + + + Keine bevorstehenden Termine + + + ) : ( + + {entries.map((entry, index) => ( + + + {/* Colored type indicator dot */} + + + {/* Date + title block */} + + + + {formatDateShort(entry.date)} + {!entry.allDay && ( + <> · {formatTime(entry.date.toISOString())} + )} + + + + + + + {entry.title} + + + + + {index < entries.length - 1 && ( + + )} + + ))} + + )} + + {/* Footer link */} + + + + Alle Termine + + + + + ); +}; + +export default UpcomingEventsWidget; diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts index 1de1dea..6ba19ba 100644 --- a/frontend/src/components/dashboard/index.ts +++ b/frontend/src/components/dashboard/index.ts @@ -6,3 +6,5 @@ export { default as BookstackCard } from './BookstackCard'; export { default as StatsCard } from './StatsCard'; export { default as ActivityFeed } from './ActivityFeed'; export { default as DashboardLayout } from './DashboardLayout'; +export { default as PersonalWarningsBanner } from './PersonalWarningsBanner'; +export { default as UpcomingEventsWidget } from './UpcomingEventsWidget'; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 5a6c560..2889596 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -13,15 +13,14 @@ import { useAuth } from '../contexts/AuthContext'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import SkeletonCard from '../components/shared/SkeletonCard'; import UserProfile from '../components/dashboard/UserProfile'; -import NextcloudCard from '../components/dashboard/NextcloudCard'; -import VikunjaCard from '../components/dashboard/VikunjaCard'; -import BookstackCard from '../components/dashboard/BookstackCard'; import NextcloudTalkWidget from '../components/dashboard/NextcloudTalkWidget'; +import UpcomingEventsWidget from '../components/dashboard/UpcomingEventsWidget'; import StatsCard from '../components/dashboard/StatsCard'; import ActivityFeed from '../components/dashboard/ActivityFeed'; import InspectionAlerts from '../components/vehicles/InspectionAlerts'; import EquipmentAlerts from '../components/equipment/EquipmentAlerts'; import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard'; +import PersonalWarningsBanner from '../components/dashboard/PersonalWarningsBanner'; import { vehiclesApi } from '../services/vehicles'; import { equipmentApi } from '../services/equipment'; import type { VehicleStats } from '../types/vehicle.types'; @@ -89,6 +88,17 @@ function Dashboard() { )} + {/* Personal Atemschutz Warnings — shown only when relevant */} + {user && ( + + + + + + + + )} + {/* Live vehicle KPI — einsatzbereit count from API */} {dataLoading ? ( @@ -143,51 +153,13 @@ function Dashboard() { - {/* Service Integration Cards */} - - - Dienste und Integrationen - - - - - {dataLoading ? ( - - ) : ( - - - console.log('Nextcloud clicked')} - /> - - - )} - - - {dataLoading ? ( - - ) : ( - - - console.log('Vikunja clicked')} - /> - - - )} - - - {dataLoading ? ( - - ) : ( - - - console.log('Bookstack clicked')} - /> - - - )} + {/* Upcoming Events Widget */} + + + + + + {/* Nextcloud Talk Widget */} diff --git a/frontend/src/pages/Fahrzeuge.tsx b/frontend/src/pages/Fahrzeuge.tsx index 5f436d2..0f530f4 100644 --- a/frontend/src/pages/Fahrzeuge.tsx +++ b/frontend/src/pages/Fahrzeuge.tsx @@ -213,15 +213,53 @@ const VehicleCard: React.FC = ({ vehicle, onClick, warnings = )} {warnings.length > 0 && ( - `${w.bezeichnung}: ${AusruestungStatusLabel[w.status]}`).join('\n')}> - } - label={`${warnings.length} Ausrüstung nicht bereit`} - color={warnings.some(w => w.status === AusruestungStatus.Beschaedigt) ? 'error' : 'warning'} - sx={{ mt: 0.5 }} - /> - + + {warnings.slice(0, warnings.length > 3 ? 2 : 3).map((w) => { + const isError = + w.status === AusruestungStatus.Beschaedigt || + w.status === AusruestungStatus.AusserDienst; + return ( + + } + label={w.bezeichnung} + color={isError ? 'error' : 'warning'} + sx={{ fontSize: '0.7rem', maxWidth: 160 }} + /> + + ); + })} + {warnings.length > 3 && ( + `${w.bezeichnung}: ${AusruestungStatusLabel[w.status]}`) + .join('\n')} + > + } + label={`+${warnings.length - 2} weitere`} + color={ + warnings + .slice(2) + .some( + (w) => + w.status === AusruestungStatus.Beschaedigt || + w.status === AusruestungStatus.AusserDienst + ) + ? 'error' + : 'warning' + } + sx={{ fontSize: '0.7rem' }} + /> + + )} + )} diff --git a/frontend/src/services/atemschutz.ts b/frontend/src/services/atemschutz.ts index b25c6c9..d8f84ee 100644 --- a/frontend/src/services/atemschutz.ts +++ b/frontend/src/services/atemschutz.ts @@ -24,6 +24,14 @@ export const atemschutzApi = { ); }, + async getMyStatus(): Promise { + const response = await api.get<{ success: boolean; data: AtemschutzUebersicht | null }>( + '/api/atemschutz/my-status' + ); + // data can be null when the user has no atemschutz entry + return response.data?.data ?? null; + }, + async getById(id: string): Promise { return unwrap( api.get<{ success: boolean; data: AtemschutzUebersicht }>(`/api/atemschutz/${id}`) diff --git a/frontend/src/services/auth.ts b/frontend/src/services/auth.ts index 3ac3734..6aa7e27 100644 --- a/frontend/src/services/auth.ts +++ b/frontend/src/services/auth.ts @@ -9,6 +9,19 @@ export interface AuthCallbackResponse { user: User; } +// The backend returns camelCase field names; map them to the snake_case User type. +function mapBackendUser(backendUser: Record): User { + return { + id: backendUser.id as string, + email: backendUser.email as string, + name: backendUser.name as string, + given_name: (backendUser.given_name ?? backendUser.givenName) as string, + family_name: (backendUser.family_name ?? backendUser.familyName) as string, + preferred_username: (backendUser.preferred_username ?? backendUser.preferredUsername) as string | undefined, + groups: backendUser.groups as string[] | undefined, + }; +} + export const authService = { /** * Generate Authentik authorization URL @@ -31,14 +44,14 @@ export const authService = { const response = await api.post<{ success: boolean; message: string; - data: { accessToken: string; refreshToken: string; user: User }; + data: { accessToken: string; refreshToken: string; user: Record }; }>('/api/auth/callback', { code, redirect_uri: REDIRECT_URI, }); return { token: response.data.data.accessToken, - user: response.data.data.user, + user: mapBackendUser(response.data.data.user), }; }, @@ -59,7 +72,7 @@ export const authService = { * Get current user information */ async getCurrentUser(): Promise { - const response = await api.get('/api/user/me'); - return response.data; + const response = await api.get<{ success: boolean; data: Record }>('/api/user/me'); + return mapBackendUser(response.data.data); }, };