featuer change for calendar
This commit is contained in:
@@ -116,6 +116,22 @@ class AtemschutzController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMyStatus(req: Request, res: Response): Promise<void> {
|
||||||
|
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<void> {
|
async delete(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params as Record<string, string>;
|
const { id } = req.params as Record<string, string>;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const router = Router();
|
|||||||
|
|
||||||
router.get('/', authenticate, atemschutzController.list.bind(atemschutzController));
|
router.get('/', authenticate, atemschutzController.list.bind(atemschutzController));
|
||||||
router.get('/stats', authenticate, atemschutzController.getStats.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));
|
router.get('/:id', authenticate, atemschutzController.getOne.bind(atemschutzController));
|
||||||
|
|
||||||
// ── Write — admin + kommandant ───────────────────────────────────────────────
|
// ── Write — admin + kommandant ───────────────────────────────────────────────
|
||||||
|
|||||||
184
frontend/src/components/dashboard/PersonalWarningsBanner.tsx
Normal file
184
frontend/src/components/dashboard/PersonalWarningsBanner.tsx
Normal 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;
|
||||||
319
frontend/src/components/dashboard/UpcomingEventsWidget.tsx
Normal file
319
frontend/src/components/dashboard/UpcomingEventsWidget.tsx
Normal 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 && (
|
||||||
|
<> · {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;
|
||||||
@@ -6,3 +6,5 @@ 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';
|
||||||
|
export { default as PersonalWarningsBanner } from './PersonalWarningsBanner';
|
||||||
|
export { default as UpcomingEventsWidget } from './UpcomingEventsWidget';
|
||||||
|
|||||||
@@ -13,15 +13,14 @@ import { useAuth } from '../contexts/AuthContext';
|
|||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import SkeletonCard from '../components/shared/SkeletonCard';
|
import SkeletonCard from '../components/shared/SkeletonCard';
|
||||||
import UserProfile from '../components/dashboard/UserProfile';
|
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 NextcloudTalkWidget from '../components/dashboard/NextcloudTalkWidget';
|
||||||
|
import UpcomingEventsWidget from '../components/dashboard/UpcomingEventsWidget';
|
||||||
import StatsCard from '../components/dashboard/StatsCard';
|
import StatsCard from '../components/dashboard/StatsCard';
|
||||||
import ActivityFeed from '../components/dashboard/ActivityFeed';
|
import ActivityFeed from '../components/dashboard/ActivityFeed';
|
||||||
import InspectionAlerts from '../components/vehicles/InspectionAlerts';
|
import InspectionAlerts from '../components/vehicles/InspectionAlerts';
|
||||||
import EquipmentAlerts from '../components/equipment/EquipmentAlerts';
|
import EquipmentAlerts from '../components/equipment/EquipmentAlerts';
|
||||||
import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard';
|
import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard';
|
||||||
|
import PersonalWarningsBanner from '../components/dashboard/PersonalWarningsBanner';
|
||||||
import { vehiclesApi } from '../services/vehicles';
|
import { vehiclesApi } from '../services/vehicles';
|
||||||
import { equipmentApi } from '../services/equipment';
|
import { equipmentApi } from '../services/equipment';
|
||||||
import type { VehicleStats } from '../types/vehicle.types';
|
import type { VehicleStats } from '../types/vehicle.types';
|
||||||
@@ -89,6 +88,17 @@ function Dashboard() {
|
|||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Personal Atemschutz Warnings — shown only when relevant */}
|
||||||
|
{user && (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '150ms' }}>
|
||||||
|
<Box>
|
||||||
|
<PersonalWarningsBanner user={user} />
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Live vehicle KPI — einsatzbereit count from API */}
|
{/* Live vehicle KPI — einsatzbereit count from API */}
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
{dataLoading ? (
|
{dataLoading ? (
|
||||||
@@ -143,51 +153,13 @@ function Dashboard() {
|
|||||||
</Fade>
|
</Fade>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Service Integration Cards */}
|
{/* Upcoming Events Widget */}
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12} md={6}>
|
||||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '440ms' }}>
|
||||||
Dienste und Integrationen
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid item xs={12} md={4}>
|
|
||||||
{dataLoading ? (
|
|
||||||
<SkeletonCard variant="basic" />
|
|
||||||
) : (
|
|
||||||
<Fade in={true} timeout={600} style={{ transitionDelay: '400ms' }}>
|
|
||||||
<Box>
|
<Box>
|
||||||
<NextcloudCard
|
<UpcomingEventsWidget />
|
||||||
onClick={() => console.log('Nextcloud clicked')}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Fade>
|
</Fade>
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} md={4}>
|
|
||||||
{dataLoading ? (
|
|
||||||
<SkeletonCard variant="basic" />
|
|
||||||
) : (
|
|
||||||
<Fade in={true} timeout={600} style={{ transitionDelay: '450ms' }}>
|
|
||||||
<Box>
|
|
||||||
<VikunjaCard
|
|
||||||
onClick={() => console.log('Vikunja clicked')}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Fade>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} md={4}>
|
|
||||||
{dataLoading ? (
|
|
||||||
<SkeletonCard variant="basic" />
|
|
||||||
) : (
|
|
||||||
<Fade in={true} timeout={600} style={{ transitionDelay: '500ms' }}>
|
|
||||||
<Box>
|
|
||||||
<BookstackCard
|
|
||||||
onClick={() => console.log('Bookstack clicked')}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Fade>
|
|
||||||
)}
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Nextcloud Talk Widget */}
|
{/* Nextcloud Talk Widget */}
|
||||||
|
|||||||
@@ -213,16 +213,54 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick, warnings =
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{warnings.length > 0 && (
|
{warnings.length > 0 && (
|
||||||
<Tooltip title={warnings.map(w => `${w.bezeichnung}: ${AusruestungStatusLabel[w.status]}`).join('\n')}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
|
||||||
|
{warnings.slice(0, warnings.length > 3 ? 2 : 3).map((w) => {
|
||||||
|
const isError =
|
||||||
|
w.status === AusruestungStatus.Beschaedigt ||
|
||||||
|
w.status === AusruestungStatus.AusserDienst;
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={w.ausruestung_id}
|
||||||
|
title={`${w.kategorie_name}: ${AusruestungStatusLabel[w.status]}`}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<ReportProblem />}
|
||||||
|
label={w.bezeichnung}
|
||||||
|
color={isError ? 'error' : 'warning'}
|
||||||
|
sx={{ fontSize: '0.7rem', maxWidth: 160 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{warnings.length > 3 && (
|
||||||
|
<Tooltip
|
||||||
|
title={warnings
|
||||||
|
.slice(2)
|
||||||
|
.map((w) => `${w.bezeichnung}: ${AusruestungStatusLabel[w.status]}`)
|
||||||
|
.join('\n')}
|
||||||
|
>
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
icon={<Warning />}
|
icon={<Warning />}
|
||||||
label={`${warnings.length} Ausrüstung nicht bereit`}
|
label={`+${warnings.length - 2} weitere`}
|
||||||
color={warnings.some(w => w.status === AusruestungStatus.Beschaedigt) ? 'error' : 'warning'}
|
color={
|
||||||
sx={{ mt: 0.5 }}
|
warnings
|
||||||
|
.slice(2)
|
||||||
|
.some(
|
||||||
|
(w) =>
|
||||||
|
w.status === AusruestungStatus.Beschaedigt ||
|
||||||
|
w.status === AusruestungStatus.AusserDienst
|
||||||
|
)
|
||||||
|
? 'error'
|
||||||
|
: 'warning'
|
||||||
|
}
|
||||||
|
sx={{ fontSize: '0.7rem' }}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</CardActionArea>
|
</CardActionArea>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ export const atemschutzApi = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getMyStatus(): Promise<AtemschutzUebersicht | null> {
|
||||||
|
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<AtemschutzUebersicht> {
|
async getById(id: string): Promise<AtemschutzUebersicht> {
|
||||||
return unwrap(
|
return unwrap(
|
||||||
api.get<{ success: boolean; data: AtemschutzUebersicht }>(`/api/atemschutz/${id}`)
|
api.get<{ success: boolean; data: AtemschutzUebersicht }>(`/api/atemschutz/${id}`)
|
||||||
|
|||||||
@@ -9,6 +9,19 @@ export interface AuthCallbackResponse {
|
|||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The backend returns camelCase field names; map them to the snake_case User type.
|
||||||
|
function mapBackendUser(backendUser: Record<string, unknown>): 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 = {
|
export const authService = {
|
||||||
/**
|
/**
|
||||||
* Generate Authentik authorization URL
|
* Generate Authentik authorization URL
|
||||||
@@ -31,14 +44,14 @@ export const authService = {
|
|||||||
const response = await api.post<{
|
const response = await api.post<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
data: { accessToken: string; refreshToken: string; user: User };
|
data: { accessToken: string; refreshToken: string; user: Record<string, unknown> };
|
||||||
}>('/api/auth/callback', {
|
}>('/api/auth/callback', {
|
||||||
code,
|
code,
|
||||||
redirect_uri: REDIRECT_URI,
|
redirect_uri: REDIRECT_URI,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
token: response.data.data.accessToken,
|
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
|
* Get current user information
|
||||||
*/
|
*/
|
||||||
async getCurrentUser(): Promise<User> {
|
async getCurrentUser(): Promise<User> {
|
||||||
const response = await api.get<User>('/api/user/me');
|
const response = await api.get<{ success: boolean; data: Record<string, unknown> }>('/api/user/me');
|
||||||
return response.data;
|
return mapBackendUser(response.data.data);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user