featuer change for calendar
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,28 +98,38 @@ 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.untersuchungAbgelaufen > 0 || stats.leistungstestAbgelaufen > 0) && (
|
||||||
<Typography variant="body2" color="error.main">
|
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
|
||||||
{stats.untersuchungAbgelaufen} Untersuchung{stats.untersuchungAbgelaufen !== 1 ? 'en' : ''} abgelaufen
|
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Abgelaufen</AlertTitle>
|
||||||
</Typography>
|
{stats.untersuchungAbgelaufen > 0 && (
|
||||||
|
<Typography variant="body2">
|
||||||
|
{stats.untersuchungAbgelaufen} Untersuchung{stats.untersuchungAbgelaufen !== 1 ? 'en' : ''} abgelaufen
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{stats.leistungstestAbgelaufen > 0 && (
|
||||||
|
<Typography variant="body2">
|
||||||
|
{stats.leistungstestAbgelaufen} Leistungstest{stats.leistungstestAbgelaufen !== 1 ? 's' : ''} abgelaufen
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{stats.leistungstestAbgelaufen > 0 && (
|
{(stats.untersuchungBaldFaellig > 0 || stats.leistungstestBaldFaellig > 0) && (
|
||||||
<Typography variant="body2" color="error.main">
|
<Alert severity="warning" variant="outlined" sx={{ py: 0.5 }}>
|
||||||
{stats.leistungstestAbgelaufen} Leistungstest{stats.leistungstestAbgelaufen !== 1 ? 's' : ''} abgelaufen
|
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Bald fällig</AlertTitle>
|
||||||
</Typography>
|
{stats.untersuchungBaldFaellig > 0 && (
|
||||||
)}
|
<Typography variant="body2">
|
||||||
{stats.untersuchungBaldFaellig > 0 && (
|
{stats.untersuchungBaldFaellig} Untersuchung{stats.untersuchungBaldFaellig !== 1 ? 'en' : ''} bald fällig
|
||||||
<Typography variant="body2" color="warning.main">
|
</Typography>
|
||||||
{stats.untersuchungBaldFaellig} Untersuchung{stats.untersuchungBaldFaellig !== 1 ? 'en' : ''} bald fällig
|
)}
|
||||||
</Typography>
|
{stats.leistungstestBaldFaellig > 0 && (
|
||||||
)}
|
<Typography variant="body2">
|
||||||
{stats.leistungstestBaldFaellig > 0 && (
|
{stats.leistungstestBaldFaellig} Leistungstest{stats.leistungstestBaldFaellig !== 1 ? 's' : ''} bald fällig
|
||||||
<Typography variant="body2" color="warning.main">
|
</Typography>
|
||||||
{stats.leistungstestBaldFaellig} Leistungstest{stats.leistungstestBaldFaellig !== 1 ? 's' : ''} bald fällig
|
)}
|
||||||
</Typography>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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,56 +140,100 @@ 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
|
||||||
{overdue.length > 0 && (
|
sx={{
|
||||||
<Alert severity="error" variant="outlined">
|
border: '1px solid',
|
||||||
<AlertTitle sx={{ fontWeight: 600 }}>Überfällig — Handlungsbedarf</AlertTitle>
|
borderColor: overdue.length > 0 ? 'error.main' : 'warning.main',
|
||||||
<Box component="ul" sx={{ m: 0, pl: 2 }}>
|
borderRadius: 1,
|
||||||
{overdue.map((w) => (
|
overflow: 'hidden',
|
||||||
<Box key={w.key} component="li" sx={{ mb: 0.5 }}>
|
}}
|
||||||
<Link
|
>
|
||||||
component={RouterLink}
|
{/* Banner header */}
|
||||||
to="/atemschutz"
|
<Box
|
||||||
color="inherit"
|
sx={{
|
||||||
underline="hover"
|
display: 'flex',
|
||||||
sx={{ fontWeight: 500 }}
|
alignItems: 'center',
|
||||||
>
|
gap: 1,
|
||||||
{w.label}
|
px: 2,
|
||||||
</Link>
|
py: 1,
|
||||||
{' — '}
|
bgcolor: overdue.length > 0 ? 'error.light' : 'warning.light',
|
||||||
<Typography component="span" variant="body2">
|
}}
|
||||||
{tageText(w.tageRest)}
|
>
|
||||||
</Typography>
|
<NotificationsActiveIcon
|
||||||
</Box>
|
fontSize="small"
|
||||||
))}
|
sx={{ color: overdue.length > 0 ? 'error.dark' : 'warning.dark' }}
|
||||||
</Box>
|
/>
|
||||||
</Alert>
|
<Typography
|
||||||
)}
|
variant="subtitle2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
color: overdue.length > 0 ? 'error.dark' : 'warning.dark',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Persönliche Warnungen ({warnings.length})
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{upcoming.length > 0 && (
|
<Divider />
|
||||||
<Alert severity="warning" variant="outlined">
|
|
||||||
<AlertTitle sx={{ fontWeight: 600 }}>Frist läuft bald ab</AlertTitle>
|
{/* Warning alerts */}
|
||||||
<Box component="ul" sx={{ m: 0, pl: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, p: 1.5 }}>
|
||||||
{upcoming.map((w) => (
|
{overdue.length > 0 && (
|
||||||
<Box key={w.key} component="li" sx={{ mb: 0.5 }}>
|
<Alert severity="error" variant="outlined">
|
||||||
<Link
|
<AlertTitle sx={{ fontWeight: 600 }}>Überfällig — Handlungsbedarf</AlertTitle>
|
||||||
component={RouterLink}
|
<Box component="ul" sx={{ m: 0, pl: 2 }}>
|
||||||
to="/atemschutz"
|
{overdue.map((w) => (
|
||||||
color="inherit"
|
<Box key={w.key} component="li" sx={{ mb: 0.5 }}>
|
||||||
underline="hover"
|
<Link
|
||||||
sx={{ fontWeight: 500 }}
|
component={RouterLink}
|
||||||
>
|
to="/atemschutz"
|
||||||
{w.label}
|
color="inherit"
|
||||||
</Link>
|
underline="hover"
|
||||||
{' — '}
|
sx={{ fontWeight: 500 }}
|
||||||
<Typography component="span" variant="body2">
|
>
|
||||||
{tageText(w.tageRest)}
|
{w.label}
|
||||||
</Typography>
|
</Link>
|
||||||
</Box>
|
{' — '}
|
||||||
))}
|
<Typography component="span" variant="body2">
|
||||||
</Box>
|
{tageText(w.tageRest)}
|
||||||
</Alert>
|
</Typography>
|
||||||
)}
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
||||||
|
→ {w.action}
|
||||||
|
</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>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
||||||
|
→ {w.action}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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' } }}
|
||||||
@@ -92,16 +110,23 @@ function Header({ onMenuClick }: HeaderProps) {
|
|||||||
aria-controls="user-menu"
|
aria-controls="user-menu"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
>
|
>
|
||||||
<Avatar
|
<Badge
|
||||||
sx={{
|
badgeContent={warningCount}
|
||||||
bgcolor: 'secondary.main',
|
color="error"
|
||||||
width: 32,
|
overlap="circular"
|
||||||
height: 32,
|
invisible={warningCount === 0}
|
||||||
fontSize: '0.875rem',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{getInitials()}
|
<Avatar
|
||||||
</Avatar>
|
sx={{
|
||||||
|
bgcolor: 'secondary.main',
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getInitials()}
|
||||||
|
</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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user