featuer change for calendar

This commit is contained in:
Matthias Hochmeister
2026-03-03 08:57:32 +01:00
parent ad069fde10
commit 146f79cf00
9 changed files with 617 additions and 64 deletions

View File

@@ -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<PersonalWarningsBannerProps> = ({ user: _user }) => {
const [record, setRecord] = useState<AtemschutzUebersicht | null>(null);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(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 (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1 }}>
<CircularProgress size={16} />
<Typography variant="body2" color="text.secondary">
Persönliche Fristen werden geprüft
</Typography>
</Box>
);
}
// ── 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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{overdue.length > 0 && (
<Alert severity="error" variant="outlined">
<AlertTitle sx={{ fontWeight: 600 }}>Überfällig Handlungsbedarf</AlertTitle>
<Box component="ul" sx={{ m: 0, pl: 2 }}>
{overdue.map((w) => (
<Box key={w.key} component="li" sx={{ mb: 0.5 }}>
<Link
component={RouterLink}
to="/atemschutz"
color="inherit"
underline="hover"
sx={{ fontWeight: 500 }}
>
{w.label}
</Link>
{' — '}
<Typography component="span" variant="body2">
{tageText(w.tageRest)}
</Typography>
</Box>
))}
</Box>
</Alert>
)}
{upcoming.length > 0 && (
<Alert severity="warning" variant="outlined">
<AlertTitle sx={{ fontWeight: 600 }}>Frist läuft bald ab</AlertTitle>
<Box component="ul" sx={{ m: 0, pl: 2 }}>
{upcoming.map((w) => (
<Box key={w.key} component="li" sx={{ mb: 0.5 }}>
<Link
component={RouterLink}
to="/atemschutz"
color="inherit"
underline="hover"
sx={{ fontWeight: 500 }}
>
{w.label}
</Link>
{' — '}
<Typography component="span" variant="body2">
{tageText(w.tageRest)}
</Typography>
</Box>
))}
</Box>
</Alert>
)}
</Box>
);
};
export default PersonalWarningsBanner;

View File

@@ -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<UebungTyp, string> = {
'Ü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<CalendarEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<Card sx={{ height: '100%' }}>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 2 }}>
<CircularProgress size={18} />
<Typography variant="body2" color="text.secondary">
Termine werden geladen...
</Typography>
</CardContent>
</Card>
);
}
// ── Error state ───────────────────────────────────────────────────────────
if (error) {
return (
<Card sx={{ height: '100%' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<CalendarMonthIcon fontSize="small" color="action" />
<Typography variant="h6">Nächste Termine</Typography>
</Box>
<Typography variant="body2" color="error">
{error}
</Typography>
</CardContent>
</Card>
);
}
// ── Main render ───────────────────────────────────────────────────────────
return (
<Card
sx={{
height: '100%',
transition: 'box-shadow 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
>
<CardContent sx={{ pb: '8px !important' }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<CalendarMonthIcon fontSize="small" sx={{ color: 'primary.main' }} />
<Typography variant="h6" component="div">
Nächste Termine
</Typography>
</Box>
<Divider sx={{ mb: 1 }} />
{/* Empty state */}
{entries.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 3 }}>
<Typography variant="body2" color="text.secondary">
Keine bevorstehenden Termine
</Typography>
</Box>
) : (
<List disablePadding>
{entries.map((entry, index) => (
<React.Fragment key={entry.id}>
<ListItem
disableGutters
sx={{
py: 1,
display: 'flex',
alignItems: 'flex-start',
gap: 1.5,
}}
>
{/* Colored type indicator dot */}
<Box
sx={{
mt: '4px',
flexShrink: 0,
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: entry.color,
}}
/>
{/* Date + title block */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
flexWrap: 'wrap',
mb: 0.25,
}}
>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>
{formatDateShort(entry.date)}
{!entry.allDay && (
<> &middot; {formatTime(entry.date.toISOString())}</>
)}
</Typography>
<Chip
label={entry.typeLabel}
size="small"
sx={{
height: 16,
fontSize: '0.65rem',
bgcolor: `${entry.color}22`,
color: entry.color,
border: `1px solid ${entry.color}55`,
fontWeight: 600,
'& .MuiChip-label': { px: '5px' },
}}
/>
</Box>
<Typography
variant="body2"
sx={{
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{entry.title}
</Typography>
</Box>
</ListItem>
{index < entries.length - 1 && (
<Divider component="li" sx={{ listStyle: 'none' }} />
)}
</React.Fragment>
))}
</List>
)}
{/* Footer link */}
<Divider sx={{ mt: 1, mb: 1 }} />
<Box sx={{ textAlign: 'right' }}>
<Link
component={RouterLink}
to="/kalender"
underline="hover"
variant="body2"
sx={{ fontWeight: 500 }}
>
Alle Termine
</Link>
</Box>
</CardContent>
</Card>
);
};
export default UpcomingEventsWidget;

View File

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