resolve issues with new features

This commit is contained in:
Matthias Hochmeister
2026-03-12 16:05:01 +01:00
parent a5cd78f01f
commit 5aa309b97a
22 changed files with 796 additions and 234 deletions

View File

@@ -28,7 +28,7 @@ import AddIcon from '@mui/icons-material/Add';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { bannerApi } from '../../services/banners';
import { useNotification } from '../../contexts/NotificationContext';
import type { BannerLevel } from '../../types/banner.types';
import type { BannerLevel, BannerShowAs } from '../../types/banner.types';
const LEVEL_LABEL: Record<BannerLevel, string> = {
info: 'Info',
@@ -53,6 +53,11 @@ function formatDateTime(iso: string | null | undefined): string {
});
}
const SHOW_AS_LABEL: Record<BannerShowAs, string> = {
banner: 'Banner',
widget: 'Widget',
};
function BannerManagementTab() {
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
@@ -60,6 +65,7 @@ function BannerManagementTab() {
const [newMessage, setNewMessage] = useState('');
const [newLevel, setNewLevel] = useState<BannerLevel>('info');
const [newEndsAt, setNewEndsAt] = useState('');
const [newShowAs, setNewShowAs] = useState<BannerShowAs>('banner');
const { data: banners, isLoading } = useQuery({
queryKey: ['admin', 'banners'],
@@ -72,6 +78,7 @@ function BannerManagementTab() {
bannerApi.create({
message: newMessage.trim(),
level: newLevel,
show_as: newShowAs,
starts_at: new Date().toISOString(),
ends_at: newEndsAt ? new Date(newEndsAt).toISOString() : null,
}),
@@ -83,6 +90,7 @@ function BannerManagementTab() {
setNewMessage('');
setNewLevel('info');
setNewEndsAt('');
setNewShowAs('banner');
},
onError: (error: any) => {
const message = error?.response?.data?.message || 'Banner konnte nicht erstellt werden';
@@ -113,6 +121,7 @@ function BannerManagementTab() {
setNewMessage('');
setNewLevel('info');
setNewEndsAt('');
setNewShowAs('banner');
};
if (isLoading) {
@@ -133,6 +142,7 @@ function BannerManagementTab() {
<TableHead>
<TableRow>
<TableCell>Stufe</TableCell>
<TableCell>Anzeige</TableCell>
<TableCell>Nachricht</TableCell>
<TableCell>Erstellt am</TableCell>
<TableCell>Ablauf</TableCell>
@@ -149,6 +159,13 @@ function BannerManagementTab() {
size="small"
/>
</TableCell>
<TableCell>
<Chip
label={SHOW_AS_LABEL[banner.show_as] ?? 'Banner'}
variant="outlined"
size="small"
/>
</TableCell>
<TableCell sx={{ maxWidth: 400 }}>{banner.message}</TableCell>
<TableCell>{formatDateTime(banner.created_at)}</TableCell>
<TableCell>{formatDateTime(banner.ends_at)}</TableCell>
@@ -166,7 +183,7 @@ function BannerManagementTab() {
))}
{(banners ?? []).length === 0 && (
<TableRow>
<TableCell colSpan={5} align="center">Keine Banner vorhanden</TableCell>
<TableCell colSpan={6} align="center">Keine Banner vorhanden</TableCell>
</TableRow>
)}
</TableBody>
@@ -200,6 +217,17 @@ function BannerManagementTab() {
<MenuItem value="critical">Kritisch</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth margin="dense">
<InputLabel>Anzeige als</InputLabel>
<Select
value={newShowAs}
label="Anzeige als"
onChange={(e) => setNewShowAs(e.target.value as BannerShowAs)}
>
<MenuItem value="banner">Banner</MenuItem>
<MenuItem value="widget">Widget</MenuItem>
</Select>
</FormControl>
<TextField
margin="dense"
label="Ablaufdatum (optional)"

View File

@@ -41,7 +41,7 @@ const ChatMessageView: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
}).catch(() => {});
}
}, [selectedRoomToken, chatPanelOpen, queryClient]);
}, [selectedRoomToken, chatPanelOpen, queryClient, messages?.length]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -83,7 +83,7 @@ const ChatMessageView: React.FC = () => {
</Box>
<Box sx={{ flex: 1, overflow: 'auto', py: 1 }}>
{messages?.map((msg) => (
{[...(messages ?? [])].reverse().map((msg) => (
<ChatMessage
key={msg.id}
message={msg}

View File

@@ -3,6 +3,7 @@ import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import IconButton from '@mui/material/IconButton';
import ChatIcon from '@mui/icons-material/Chat';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import Typography from '@mui/material/Typography';
import Avatar from '@mui/material/Avatar';
import Badge from '@mui/material/Badge';
@@ -12,6 +13,10 @@ import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import { useLayout } from '../../contexts/LayoutContext';
import { ChatProvider, useChat } from '../../contexts/ChatContext';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { configApi } from '../../services/config';
import { safeOpenUrl } from '../../utils/safeOpenUrl';
import ChatRoomList from './ChatRoomList';
import ChatMessageView from './ChatMessageView';
@@ -22,6 +27,12 @@ const EXPANDED_WIDTH = 360;
const ChatPanelInner: React.FC = () => {
const { chatPanelOpen, setChatPanelOpen } = useLayout();
const { rooms, selectedRoomToken, selectRoom, connected } = useChat();
const { data: externalLinks } = useQuery({
queryKey: ['external-links'],
queryFn: () => configApi.getExternalLinks(),
staleTime: 10 * 60 * 1000,
});
const nextcloudUrl = externalLinks?.nextcloud;
if (!chatPanelOpen) {
return (
@@ -120,15 +131,24 @@ const ChatPanelInner: React.FC = () => {
<Typography variant="subtitle1" fontWeight={600}>
Chat
</Typography>
<IconButton size="small" onClick={() => setChatPanelOpen(false)} aria-label="Chat einklappen">
<ChatIcon fontSize="small" />
</IconButton>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{nextcloudUrl && (
<Tooltip title="In Nextcloud öffnen" placement="bottom">
<IconButton size="small" onClick={() => safeOpenUrl(`${nextcloudUrl}/apps/spreed`)} aria-label="In Nextcloud öffnen">
<OpenInNewIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<IconButton size="small" onClick={() => setChatPanelOpen(false)} aria-label="Chat einklappen">
<ChatIcon fontSize="small" />
</IconButton>
</Box>
</Box>
{!connected ? (
<Box sx={{ p: 2 }}>
<Typography variant="body2" color="text.secondary">
Nextcloud nicht verbunden. Bitte verbinden Sie sich in den Einstellungen.
Nextcloud nicht verbunden. Bitte verbinden Sie sich in den <Link to="/settings" style={{ color: 'inherit' }}>Einstellungen</Link>.
</Typography>
</Box>
) : selectedRoomToken ? (

View File

@@ -37,7 +37,7 @@ export default function AnnouncementBanner({ gridColumn }: { gridColumn?: string
retry: 1,
});
const visible = banners.filter(b => !dismissed.includes(b.id) || b.level === 'critical');
const visible = banners.filter(b => b.show_as !== 'widget' && (!dismissed.includes(b.id) || b.level === 'critical'));
const handleDismiss = (banner: Banner) => {
if (banner.level === 'critical') return; // never dismiss critical

View File

@@ -0,0 +1,51 @@
import { Card, CardContent, Typography, Box } from '@mui/material';
import { Campaign } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { bannerApi } from '../../services/banners';
import type { BannerLevel } from '../../types/banner.types';
const SEVERITY_COLOR: Record<BannerLevel, string> = {
info: '#1976d2',
important: '#ed6c02',
critical: '#d32f2f',
};
export default function BannerWidget() {
const { data: banners = [] } = useQuery({
queryKey: ['banners', 'active'],
queryFn: bannerApi.getActive,
refetchInterval: 60_000,
retry: 1,
});
const widgetBanners = banners.filter(b => b.show_as === 'widget');
if (widgetBanners.length === 0) return null;
return (
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Campaign color="primary" />
<Typography variant="h6">Mitteilungen</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{widgetBanners.map(banner => (
<Box
key={banner.id}
sx={{
borderLeft: `4px solid ${SEVERITY_COLOR[banner.level]}`,
pl: 2,
py: 1,
}}
>
<Typography variant="body2">
{banner.message}
</Typography>
</Box>
))}
</Box>
</CardContent>
</Card>
);
}

View File

@@ -165,7 +165,7 @@ const VikunjaMyTasksWidget: React.FC = () => {
key={task.id}
task={task}
showDivider={index < tasks.length - 1}
vikunjaUrl={import.meta.env.VITE_VIKUNJA_URL ?? ''}
vikunjaUrl={data?.vikunjaUrl ?? ''}
/>
))}
</Box>

View File

@@ -0,0 +1,49 @@
import { Box, Typography } from '@mui/material';
interface WidgetGroupProps {
title: string;
children: React.ReactNode;
gridColumn?: string;
}
function WidgetGroup({ title, children, gridColumn }: WidgetGroupProps) {
return (
<Box
sx={{
position: 'relative',
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
p: 2,
pt: 3,
gridColumn,
}}
>
<Typography
sx={{
position: 'absolute',
top: -10,
left: 16,
px: 1,
backgroundColor: 'background.default',
fontSize: '0.75rem',
color: 'text.secondary',
fontWeight: 500,
}}
>
{title}
</Typography>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: 2.5,
}}
>
{children}
</Box>
</Box>
);
}
export default WidgetGroup;

View File

@@ -13,4 +13,6 @@ export { default as AdminStatusWidget } from './AdminStatusWidget';
export { default as VehicleBookingQuickAddWidget } from './VehicleBookingQuickAddWidget';
export { default as EventQuickAddWidget } from './EventQuickAddWidget';
export { default as AnnouncementBanner } from './AnnouncementBanner';
export { default as BannerWidget } from './BannerWidget';
export { default as LinksWidget } from './LinksWidget';
export { default as WidgetGroup } from './WidgetGroup';

View File

@@ -9,7 +9,6 @@ import { useAuth } from '../contexts/AuthContext';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import SkeletonCard from '../components/shared/SkeletonCard';
import UserProfile from '../components/dashboard/UserProfile';
import NextcloudTalkWidget from '../components/dashboard/NextcloudTalkWidget';
import UpcomingEventsWidget from '../components/dashboard/UpcomingEventsWidget';
import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard';
import EquipmentDashboardCard from '../components/equipment/EquipmentDashboardCard';
@@ -24,6 +23,8 @@ import AnnouncementBanner from '../components/dashboard/AnnouncementBanner';
import VehicleBookingQuickAddWidget from '../components/dashboard/VehicleBookingQuickAddWidget';
import EventQuickAddWidget from '../components/dashboard/EventQuickAddWidget';
import LinksWidget from '../components/dashboard/LinksWidget';
import BannerWidget from '../components/dashboard/BannerWidget';
import WidgetGroup from '../components/dashboard/WidgetGroup';
import { preferencesApi } from '../services/settings';
import { WidgetKey } from '../constants/widgets';
@@ -84,155 +85,122 @@ function Dashboard() {
</Box>
)}
{/* Vehicle Status Card */}
{widgetVisible('vehicles') && (
<Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '300ms' }}>
<Box>
<VehicleDashboardCard />
</Box>
</Fade>
</Box>
)}
{/* Equipment Status Card */}
{widgetVisible('equipment') && (
<Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '350ms' }}>
<Box>
<EquipmentDashboardCard />
</Box>
</Fade>
</Box>
)}
{/* Atemschutz Status Card */}
{canViewAtemschutz && widgetVisible('atemschutz') && (
<Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '400ms' }}>
<Box>
<AtemschutzDashboardCard />
</Box>
</Fade>
</Box>
)}
{/* Upcoming Events Widget */}
{widgetVisible('events') && (
<Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '440ms' }}>
<Box>
<UpcomingEventsWidget />
</Box>
</Fade>
</Box>
)}
{/* Nextcloud Talk Widget */}
{widgetVisible('nextcloudTalk') && (
<Box>
{dataLoading ? (
<SkeletonCard variant="basic" />
) : (
<Fade in={true} timeout={600} style={{ transitionDelay: '480ms' }}>
<Box>
<NextcloudTalkWidget />
</Box>
</Fade>
)}
</Box>
)}
{/* BookStack Recent Pages Widget */}
{widgetVisible('bookstackRecent') && (
<Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '520ms' }}>
<Box>
<BookStackRecentWidget />
</Box>
</Fade>
</Box>
)}
{/* BookStack Search Widget */}
{widgetVisible('bookstackSearch') && (
<Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '560ms' }}>
<Box>
<BookStackSearchWidget />
</Box>
</Fade>
</Box>
)}
{/* Vikunja — My Tasks Widget */}
{widgetVisible('vikunjaTasks') && (
<Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '600ms' }}>
<Box>
<VikunjaMyTasksWidget />
</Box>
</Fade>
</Box>
)}
{/* Vikunja — Quick Add Widget */}
{widgetVisible('vikunjaQuickAdd') && (
<Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '640ms' }}>
<Box>
<VikunjaQuickAddWidget />
</Box>
</Fade>
</Box>
)}
{/* Vehicle Booking — Quick Add Widget */}
{canWrite && widgetVisible('vehicleBooking') && (
<Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '720ms' }}>
<Box>
<VehicleBookingQuickAddWidget />
</Box>
</Fade>
</Box>
)}
{/* Event — Quick Add Widget */}
{canWrite && widgetVisible('eventQuickAdd') && (
<Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '760ms' }}>
<Box>
<EventQuickAddWidget />
</Box>
</Fade>
</Box>
)}
{/* Vikunja — Overdue Notifier (invisible, polling component) */}
<VikunjaOverdueNotifier />
{/* Links Widget */}
{widgetVisible('links') && (
<Box>
{/* Status Group */}
<WidgetGroup title="Status" gridColumn="1 / -1">
{widgetVisible('vehicles') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '300ms' }}>
<Box>
<VehicleDashboardCard />
</Box>
</Fade>
)}
{widgetVisible('equipment') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '350ms' }}>
<Box>
<EquipmentDashboardCard />
</Box>
</Fade>
)}
{canViewAtemschutz && widgetVisible('atemschutz') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '400ms' }}>
<Box>
<AtemschutzDashboardCard />
</Box>
</Fade>
)}
{isAdmin && widgetVisible('adminStatus') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '440ms' }}>
<Box>
<AdminStatusWidget />
</Box>
</Fade>
)}
</WidgetGroup>
{/* Kalender Group */}
<WidgetGroup title="Kalender" gridColumn="1 / -1">
{widgetVisible('events') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '480ms' }}>
<Box>
<UpcomingEventsWidget />
</Box>
</Fade>
)}
{canWrite && widgetVisible('vehicleBooking') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '520ms' }}>
<Box>
<VehicleBookingQuickAddWidget />
</Box>
</Fade>
)}
{canWrite && widgetVisible('eventQuickAdd') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '560ms' }}>
<Box>
<EventQuickAddWidget />
</Box>
</Fade>
)}
</WidgetGroup>
{/* Dienste Group */}
<WidgetGroup title="Dienste" gridColumn="1 / -1">
{widgetVisible('bookstackRecent') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '600ms' }}>
<Box>
<BookStackRecentWidget />
</Box>
</Fade>
)}
{widgetVisible('bookstackSearch') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '640ms' }}>
<Box>
<BookStackSearchWidget />
</Box>
</Fade>
)}
{widgetVisible('vikunjaTasks') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '680ms' }}>
<Box>
<VikunjaMyTasksWidget />
</Box>
</Fade>
)}
{widgetVisible('vikunjaQuickAdd') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '720ms' }}>
<Box>
<VikunjaQuickAddWidget />
</Box>
</Fade>
)}
</WidgetGroup>
{/* Information Group */}
<WidgetGroup title="Information" gridColumn="1 / -1">
{widgetVisible('links') && (
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '760ms' }}>
<Box>
<LinksWidget />
</Box>
</Fade>
)}
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '790ms' }}>
<Box>
<LinksWidget />
<BannerWidget />
</Box>
</Fade>
</Box>
)}
{/* Admin Status Widget — only for admins */}
{isAdmin && widgetVisible('adminStatus') && (
<Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '680ms' }}>
<Box>
<AdminStatusWidget />
</Box>
</Fade>
</Box>
)}
</WidgetGroup>
</Box>
</Container>
</DashboardLayout>

View File

@@ -408,7 +408,7 @@ function FahrzeugBuchungen() {
align="center"
sx={{
fontWeight: isToday(day) ? 700 : 400,
color: isToday(day) ? 'primary.main' : 'text.primary',
color: isToday(day) ? 'primary.main' : 'text.secondary',
bgcolor: isToday(day) ? (theme) => theme.palette.mode === 'dark' ? 'primary.900' : 'primary.50' : undefined,
}}
>

View File

@@ -18,7 +18,6 @@ import { ArrowBack, Save } from '@mui/icons-material';
import { useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { vehiclesApi } from '../services/vehicles';
import { toGermanDate, fromGermanDate } from '../utils/dateInput';
import {
FahrzeugStatus,
FahrzeugStatusLabel,
@@ -65,9 +64,12 @@ const EMPTY_FORM: FormState = {
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Convert a Date ISO string like '2026-03-15T00:00:00.000Z' to 'DD.MM.YYYY' */
/** Convert a Date ISO string like '2026-03-15T00:00:00.000Z' to 'YYYY-MM-DD' for type="date" inputs */
function toDateInput(iso: string | null | undefined): string {
return toGermanDate(iso);
if (!iso) return '';
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
return '';
}
// ── Component ─────────────────────────────────────────────────────────────────
@@ -169,8 +171,8 @@ function FahrzeugForm() {
status_bemerkung: form.status_bemerkung.trim() || undefined,
standort: form.standort.trim() || 'Feuerwehrhaus',
bild_url: form.bild_url.trim() || undefined,
paragraph57a_faellig_am: form.paragraph57a_faellig_am ? fromGermanDate(form.paragraph57a_faellig_am) || undefined : undefined,
naechste_wartung_am: form.naechste_wartung_am ? fromGermanDate(form.naechste_wartung_am) || undefined : undefined,
paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined,
naechste_wartung_am: form.naechste_wartung_am || undefined,
};
await vehiclesApi.update(id, payload);
navigate(`/fahrzeuge/${id}`);
@@ -188,8 +190,8 @@ function FahrzeugForm() {
status_bemerkung: form.status_bemerkung.trim() || undefined,
standort: form.standort.trim() || 'Feuerwehrhaus',
bild_url: form.bild_url.trim() || undefined,
paragraph57a_faellig_am: form.paragraph57a_faellig_am ? fromGermanDate(form.paragraph57a_faellig_am) || undefined : undefined,
naechste_wartung_am: form.naechste_wartung_am ? fromGermanDate(form.naechste_wartung_am) || undefined : undefined,
paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined,
naechste_wartung_am: form.naechste_wartung_am || undefined,
};
const newVehicle = await vehiclesApi.create(payload);
navigate(`/fahrzeuge/${newVehicle.id}`);
@@ -315,7 +317,7 @@ function FahrzeugForm() {
<TextField
label="§57a fällig am"
fullWidth
placeholder="TT.MM.JJJJ"
type="date"
value={form.paragraph57a_faellig_am}
onChange={(e) => setForm((prev) => ({ ...prev, paragraph57a_faellig_am: e.target.value }))}
InputLabelProps={{ shrink: true }}
@@ -326,7 +328,7 @@ function FahrzeugForm() {
<TextField
label="Nächste Wartung am"
fullWidth
placeholder="TT.MM.JJJJ"
type="date"
value={form.naechste_wartung_am}
onChange={(e) => setForm((prev) => ({ ...prev, naechste_wartung_am: e.target.value }))}
InputLabelProps={{ shrink: true }}

View File

@@ -64,6 +64,8 @@ import {
Today as TodayIcon,
Tune,
ViewList as ListViewIcon,
ViewDay as ViewDayIcon,
ViewWeek as ViewWeekIcon,
Warning,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
@@ -97,6 +99,10 @@ import {
format as fnsFormat,
startOfWeek,
endOfWeek,
startOfMonth,
endOfMonth,
addDays,
subDays,
addWeeks,
subWeeks,
eachDayOfInterval,
@@ -1571,8 +1577,11 @@ export default function Kalender() {
year: today.getFullYear(),
month: today.getMonth(),
});
const [viewMode, setViewMode] = useState<'calendar' | 'list'>('calendar');
const [viewMode, setViewMode] = useState<'calendar' | 'list' | 'day' | 'week'>('calendar');
const [currentDate, setCurrentDate] = useState(new Date());
const [selectedKategorie, setSelectedKategorie] = useState<string | 'all'>('all');
const [listFrom, setListFrom] = useState(() => fnsFormat(startOfMonth(today), 'yyyy-MM-dd'));
const [listTo, setListTo] = useState(() => fnsFormat(endOfMonth(today), 'yyyy-MM-dd'));
const [trainingEvents, setTrainingEvents] = useState<UebungListItem[]>([]);
const [veranstaltungen, setVeranstaltungen] = useState<VeranstaltungListItem[]>([]);
@@ -1696,23 +1705,37 @@ export default function Kalender() {
// ── Calendar tab helpers ─────────────────────────────────────────────────────
const handlePrev = () => {
setViewMonth((prev) => {
const m = prev.month === 0 ? 11 : prev.month - 1;
const y = prev.month === 0 ? prev.year - 1 : prev.year;
return { year: y, month: m };
});
if (viewMode === 'day') {
setCurrentDate((d) => subDays(d, 1));
} else if (viewMode === 'week') {
setCurrentDate((d) => subWeeks(d, 1));
} else {
setViewMonth((prev) => {
const m = prev.month === 0 ? 11 : prev.month - 1;
const y = prev.month === 0 ? prev.year - 1 : prev.year;
return { year: y, month: m };
});
}
};
const handleNext = () => {
setViewMonth((prev) => {
const m = prev.month === 11 ? 0 : prev.month + 1;
const y = prev.month === 11 ? prev.year + 1 : prev.year;
return { year: y, month: m };
});
if (viewMode === 'day') {
setCurrentDate((d) => addDays(d, 1));
} else if (viewMode === 'week') {
setCurrentDate((d) => addWeeks(d, 1));
} else {
setViewMonth((prev) => {
const m = prev.month === 11 ? 0 : prev.month + 1;
const y = prev.month === 11 ? prev.year + 1 : prev.year;
return { year: y, month: m };
});
}
};
const handleToday = () => {
setViewMonth({ year: today.getFullYear(), month: today.getMonth() });
const now = new Date();
setViewMonth({ year: now.getFullYear(), month: now.getMonth() });
setCurrentDate(now);
};
const handleDayClick = useCallback(
@@ -1742,23 +1765,59 @@ export default function Kalender() {
});
}, [veranstaltungen, popoverDay, selectedKategorie]);
// Filtered lists for list view (current month only)
// Filtered lists for list view (filtered by date range)
const trainingForMonth = useMemo(
() =>
trainingEvents.filter((t) => {
() => {
const from = parseISO(listFrom);
const to = parseISO(listTo);
return trainingEvents.filter((t) => {
const d = new Date(t.datum_von);
return d.getMonth() === viewMonth.month && d.getFullYear() === viewMonth.year;
}),
[trainingEvents, viewMonth]
return d >= startOfDay(from) && d <= new Date(to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59);
});
},
[trainingEvents, listFrom, listTo]
);
const eventsForMonth = useMemo(
() =>
veranstaltungen.filter((ev) => {
() => {
const from = parseISO(listFrom);
const to = parseISO(listTo);
return veranstaltungen.filter((ev) => {
const d = new Date(ev.datum_von);
return d.getMonth() === viewMonth.month && d.getFullYear() === viewMonth.year;
}),
[veranstaltungen, viewMonth]
return d >= startOfDay(from) && d <= new Date(to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59);
});
},
[veranstaltungen, listFrom, listTo]
);
// Events for the selected day (day view)
const trainingForCurrentDay = useMemo(
() => trainingEvents.filter((t) => sameDay(new Date(t.datum_von), currentDate)),
[trainingEvents, currentDate]
);
const eventsForCurrentDay = useMemo(
() => veranstaltungen.filter((ev) => {
const start = startOfDay(new Date(ev.datum_von));
const end = startOfDay(new Date(ev.datum_bis));
const cur = startOfDay(currentDate);
return cur >= start && cur <= end;
}),
[veranstaltungen, currentDate]
);
// Events for the selected week (week view)
const currentWeekStartCal = useMemo(
() => startOfWeek(currentDate, { weekStartsOn: 1 }),
[currentDate]
);
const currentWeekEndCal = useMemo(
() => endOfWeek(currentDate, { weekStartsOn: 1 }),
[currentDate]
);
const weekDaysCal = useMemo(
() => eachDayOfInterval({ start: currentWeekStartCal, end: currentWeekEndCal }),
[currentWeekStartCal, currentWeekEndCal]
);
// ── Veranstaltung cancel ─────────────────────────────────────────────────────
@@ -1991,6 +2050,24 @@ export default function Kalender() {
>
{/* View toggle */}
<ButtonGroup size="small" variant="outlined">
<Tooltip title="Tagesansicht">
<Button
onClick={() => setViewMode('day')}
variant={viewMode === 'day' ? 'contained' : 'outlined'}
>
<ViewDayIcon fontSize="small" />
{!isMobile && <Box sx={{ ml: 0.5 }}>Tag</Box>}
</Button>
</Tooltip>
<Tooltip title="Wochenansicht">
<Button
onClick={() => setViewMode('week')}
variant={viewMode === 'week' ? 'contained' : 'outlined'}
>
<ViewWeekIcon fontSize="small" />
{!isMobile && <Box sx={{ ml: 0.5 }}>Woche</Box>}
</Button>
</Tooltip>
<Tooltip title="Monatsansicht">
<Button
onClick={() => setViewMode('calendar')}
@@ -2051,22 +2128,20 @@ export default function Kalender() {
</Tooltip>
)}
{/* PDF Export — only in list view */}
{viewMode === 'list' && (
<Tooltip title="Als PDF exportieren">
<IconButton
size="small"
onClick={() => generatePdf(
viewMonth.year,
viewMonth.month,
trainingForMonth,
eventsForMonth,
)}
>
<PdfIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{/* PDF Export — available in all views */}
<Tooltip title="Als PDF exportieren">
<IconButton
size="small"
onClick={() => generatePdf(
viewMonth.year,
viewMonth.month,
trainingForMonth,
eventsForMonth,
)}
>
<PdfIcon fontSize="small" />
</IconButton>
</Tooltip>
{/* CSV Import */}
{canWriteEvents && (
@@ -2091,7 +2166,7 @@ export default function Kalender() {
</Button>
</Box>
{/* Month navigation */}
{/* Navigation */}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, gap: 1 }}>
<IconButton onClick={handlePrev} size="small">
<ChevronLeft />
@@ -2100,7 +2175,11 @@ export default function Kalender() {
variant="h6"
sx={{ flexGrow: 1, textAlign: 'center', fontWeight: 600 }}
>
{MONTH_LABELS[viewMonth.month]} {viewMonth.year}
{viewMode === 'day'
? formatDateLong(currentDate)
: viewMode === 'week'
? `KW ${fnsFormat(currentWeekStartCal, 'w')}${fnsFormat(currentWeekStartCal, 'dd.MM.')} ${fnsFormat(currentWeekEndCal, 'dd.MM.yyyy')}`
: `${MONTH_LABELS[viewMonth.month]} ${viewMonth.year}`}
</Typography>
<Button
size="small"
@@ -2121,13 +2200,171 @@ export default function Kalender() {
</Alert>
)}
{/* Calendar / List body */}
{/* Calendar / List / Day / Week body */}
{calLoading ? (
<Skeleton
variant="rectangular"
height={isMobile ? 320 : 480}
sx={{ borderRadius: 2 }}
/>
) : viewMode === 'day' ? (
/* ── Day View ── */
<Paper elevation={1} sx={{ p: 2 }}>
{trainingForCurrentDay.length === 0 && eventsForCurrentDay.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
Keine Einträge an diesem Tag.
</Typography>
) : (
<List disablePadding>
{trainingForCurrentDay.length > 0 && (
<Typography variant="overline" sx={{ px: 0.5, color: 'text.secondary' }}>
Dienste
</Typography>
)}
{trainingForCurrentDay.map((t) => (
<ListItem
key={`t-${t.id}`}
sx={{
borderLeft: `4px solid ${TYP_DOT_COLOR[t.typ]}`,
borderRadius: 1,
mb: 0.5,
cursor: 'pointer',
'&:hover': { bgcolor: 'action.hover' },
opacity: t.abgesagt ? 0.55 : 1,
}}
onClick={() => navigate(`/training/${t.id}`)}
>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{t.pflichtveranstaltung && <StarIcon sx={{ fontSize: 14, color: 'warning.main' }} />}
<Typography variant="body2" sx={{ fontWeight: t.pflichtveranstaltung ? 700 : 400, textDecoration: t.abgesagt ? 'line-through' : 'none' }}>
{t.titel}
</Typography>
</Box>
}
secondary={`${formatTime(t.datum_von)} ${formatTime(t.datum_bis)} Uhr · ${t.typ}`}
/>
<RsvpDot status={t.eigener_status} />
</ListItem>
))}
{trainingForCurrentDay.length > 0 && eventsForCurrentDay.length > 0 && <Divider sx={{ my: 1 }} />}
{eventsForCurrentDay.length > 0 && (
<Typography variant="overline" sx={{ px: 0.5, color: 'text.secondary' }}>
Veranstaltungen
</Typography>
)}
{eventsForCurrentDay
.filter((ev) => selectedKategorie === 'all' || ev.kategorie_id === selectedKategorie)
.map((ev) => (
<ListItem
key={`e-${ev.id}`}
sx={{
borderLeft: `4px solid ${ev.kategorie_farbe ?? '#1976d2'}`,
borderRadius: 1,
mb: 0.5,
opacity: ev.abgesagt ? 0.55 : 1,
}}
>
<ListItemText
primary={
<Typography variant="body2" sx={{ fontWeight: 500, textDecoration: ev.abgesagt ? 'line-through' : 'none' }}>
{ev.titel}
{ev.abgesagt && <Chip label="Abgesagt" size="small" color="error" variant="outlined" sx={{ ml: 0.5, fontSize: '0.6rem', height: 16 }} />}
</Typography>
}
secondary={
<>
{ev.ganztaegig ? 'Ganztägig' : `${formatTime(ev.datum_von)} ${formatTime(ev.datum_bis)} Uhr`}
{ev.ort && ` · ${ev.ort}`}
</>
}
/>
</ListItem>
))}
</List>
)}
</Paper>
) : viewMode === 'week' ? (
/* ── Week View ── */
<Paper elevation={1} sx={{ p: 1, overflowX: 'auto' }}>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px', minWidth: 700 }}>
{weekDaysCal.map((day) => {
const isToday = sameDay(day, new Date());
const dayTraining = trainingEvents.filter((t) => sameDay(new Date(t.datum_von), day));
const dayEvents = veranstaltungen.filter((ev) => {
if (selectedKategorie !== 'all' && ev.kategorie_id !== selectedKategorie) return false;
const start = startOfDay(new Date(ev.datum_von));
const end = startOfDay(new Date(ev.datum_bis));
const cur = startOfDay(day);
return cur >= start && cur <= end;
});
return (
<Box
key={day.toISOString()}
sx={{
minHeight: 120,
border: '1px solid',
borderColor: isToday ? 'primary.main' : 'divider',
borderRadius: 1,
p: 0.5,
bgcolor: isToday ? 'primary.main' : 'background.paper',
}}
>
<Typography
variant="caption"
sx={{
display: 'block',
textAlign: 'center',
fontWeight: 600,
color: isToday ? 'primary.contrastText' : 'text.primary',
mb: 0.5,
}}
>
{fnsFormat(day, 'EEE dd.MM.', { locale: de })}
</Typography>
{dayTraining.map((t) => (
<Chip
key={`t-${t.id}`}
label={t.titel}
size="small"
onClick={() => navigate(`/training/${t.id}`)}
sx={{
fontSize: '0.6rem',
height: 18,
mb: '2px',
width: '100%',
justifyContent: 'flex-start',
bgcolor: t.abgesagt ? 'action.disabledBackground' : TYP_DOT_COLOR[t.typ],
color: t.abgesagt ? 'text.disabled' : 'white',
textDecoration: t.abgesagt ? 'line-through' : 'none',
cursor: 'pointer',
}}
/>
))}
{dayEvents.map((ev) => (
<Chip
key={`e-${ev.id}`}
label={ev.titel}
size="small"
sx={{
fontSize: '0.6rem',
height: 18,
mb: '2px',
width: '100%',
justifyContent: 'flex-start',
bgcolor: ev.abgesagt ? 'action.disabledBackground' : (ev.kategorie_farbe ?? '#1976d2'),
color: ev.abgesagt ? 'text.disabled' : 'white',
textDecoration: ev.abgesagt ? 'line-through' : 'none',
}}
/>
))}
</Box>
);
})}
</Box>
</Paper>
) : viewMode === 'calendar' ? (
<Paper elevation={1} sx={{ p: 1 }}>
<MonthCalendar
@@ -2140,24 +2377,47 @@ export default function Kalender() {
/>
</Paper>
) : (
<Paper elevation={1} sx={{ px: 1 }}>
<CombinedListView
trainingEvents={trainingForMonth}
veranstaltungen={eventsForMonth}
selectedKategorie={selectedKategorie}
canWriteEvents={canWriteEvents}
onTrainingClick={(id) => navigate(`/training/${id}`)}
onEventEdit={(ev) => {
setVeranstEditing(ev);
setVeranstFormOpen(true);
}}
onEventCancel={(id) => {
setCancelEventId(id);
setCancelEventGrund('');
}}
onEventDelete={(id) => setDeleteEventId(id)}
/>
</Paper>
<>
{/* Date range inputs for list view */}
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
type="date"
label="Von"
size="small"
value={listFrom}
onChange={(e) => setListFrom(e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ width: 170 }}
/>
<TextField
type="date"
label="Bis"
size="small"
value={listTo}
onChange={(e) => setListTo(e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ width: 170 }}
/>
</Box>
<Paper elevation={1} sx={{ px: 1 }}>
<CombinedListView
trainingEvents={trainingForMonth}
veranstaltungen={eventsForMonth}
selectedKategorie={selectedKategorie}
canWriteEvents={canWriteEvents}
onTrainingClick={(id) => navigate(`/training/${id}`)}
onEventEdit={(ev) => {
setVeranstEditing(ev);
setVeranstFormOpen(true);
}}
onEventCancel={(id) => {
setCancelEventId(id);
setCancelEventGrund('');
}}
onEventDelete={(id) => setDeleteEventId(id)}
/>
</Paper>
</>
)}
{/* FAB: Create Veranstaltung */}

View File

@@ -73,8 +73,19 @@ function useDebounce<T>(value: T, delay: number): T {
// ----------------------------------------------------------------
function Mitglieder() {
const navigate = useNavigate();
const { user } = useAuth();
const canWrite = useCanWrite();
// --- redirect non-admin/non-kommando users to their own profile ---
useEffect(() => {
if (!user) return;
const groups: string[] = (user as any)?.groups ?? [];
const isAdmin = groups.includes('dashboard_admin') || groups.includes('dashboard_kommando');
if (!isAdmin) {
navigate(`/mitglieder/${(user as any).id}`, { replace: true });
}
}, [user, navigate]);
// --- data state ---
const [members, setMembers] = useState<MemberListItem[]>([]);
const [total, setTotal] = useState(0);

View File

@@ -1,3 +1,4 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import {
Container,
Typography,
@@ -12,17 +13,25 @@ import {
ToggleButtonGroup,
ToggleButton,
CircularProgress,
Button,
Chip,
} from '@mui/material';
import { Settings as SettingsIcon, Notifications, Palette, Language, SettingsBrightness, LightMode, DarkMode, Widgets } from '@mui/icons-material';
import { Settings as SettingsIcon, Notifications, Palette, Language, SettingsBrightness, LightMode, DarkMode, Widgets, Cloud, LinkOff, Forum } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useThemeMode } from '../contexts/ThemeContext';
import { preferencesApi } from '../services/settings';
import { WIDGETS, WidgetKey } from '../constants/widgets';
import { nextcloudApi } from '../services/nextcloud';
import { useNotification } from '../contexts/NotificationContext';
const POLL_INTERVAL = 2000;
const POLL_TIMEOUT = 5 * 60 * 1000;
function Settings() {
const { themeMode, setThemeMode } = useThemeMode();
const queryClient = useQueryClient();
const { showInfo } = useNotification();
const { data: preferences, isLoading: prefsLoading } = useQuery({
queryKey: ['user-preferences'],
@@ -47,6 +56,98 @@ function Settings() {
mutation.mutate({ ...current, widgets });
};
// Nextcloud Talk connection
const { data: ncData, isLoading: ncLoading } = useQuery({
queryKey: ['nextcloud-talk-rooms'],
queryFn: () => nextcloudApi.getRooms(),
retry: 1,
});
const ncConnected = ncData?.connected ?? false;
const ncLoginName = ncData?.loginName;
const [isConnecting, setIsConnecting] = useState(false);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const popupRef = useRef<Window | null>(null);
// Show one-time info snackbar when not connected
useEffect(() => {
if (!ncLoading && !ncConnected && !sessionStorage.getItem('nextcloud-talk-notified')) {
sessionStorage.setItem('nextcloud-talk-notified', '1');
showInfo('Nextcloud Talk ist nicht verbunden. Verbinde dich in den Einstellungen.');
}
}, [ncLoading, ncConnected, showInfo]);
const stopPolling = useCallback(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
if (popupRef.current && !popupRef.current.closed) {
popupRef.current.close();
}
popupRef.current = null;
setIsConnecting(false);
}, []);
useEffect(() => {
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
if (popupRef.current && !popupRef.current.closed) {
popupRef.current.close();
}
};
}, []);
const handleConnect = async () => {
try {
setIsConnecting(true);
const { loginUrl, pollToken, pollEndpoint } = await nextcloudApi.connect();
const popup = window.open(loginUrl, '_blank', 'width=600,height=700');
popupRef.current = popup;
const startTime = Date.now();
pollIntervalRef.current = setInterval(async () => {
if (Date.now() - startTime > POLL_TIMEOUT) {
stopPolling();
return;
}
if (popup && popup.closed) {
stopPolling();
return;
}
try {
const result = await nextcloudApi.poll(pollToken, pollEndpoint);
if (result.completed) {
stopPolling();
queryClient.invalidateQueries({ queryKey: ['nextcloud-talk'] });
queryClient.invalidateQueries({ queryKey: ['nextcloud-talk-rooms'] });
}
} catch {
// Polling error — keep trying until timeout
}
}, POLL_INTERVAL);
} catch {
setIsConnecting(false);
}
};
const handleDisconnect = async () => {
try {
await nextcloudApi.disconnect();
queryClient.invalidateQueries({ queryKey: ['nextcloud-talk'] });
queryClient.invalidateQueries({ queryKey: ['nextcloud-talk-rooms'] });
} catch {
// Disconnect failed silently
}
};
return (
<DashboardLayout>
<Container maxWidth="lg">
@@ -89,6 +190,67 @@ function Settings() {
</Card>
</Grid>
{/* Nextcloud Talk */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Forum color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Nextcloud Talk</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
{ncLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={24} />
</Box>
) : ncConnected ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label="Verbunden" size="small" color="success" />
{ncLoginName && (
<Typography variant="body2" color="text.secondary">
als {ncLoginName}
</Typography>
)}
</Box>
<Button
variant="outlined"
color="error"
startIcon={<LinkOff />}
onClick={handleDisconnect}
size="small"
>
Verbindung trennen
</Button>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label="Nicht verbunden" size="small" color="default" />
</Box>
{isConnecting ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={20} />
<Typography variant="body2" color="text.secondary">
Warte auf Bestätigung...
</Typography>
</Box>
) : (
<Button
variant="outlined"
startIcon={<Cloud />}
onClick={handleConnect}
size="small"
>
Mit Nextcloud verbinden
</Button>
)}
</Box>
)}
</CardContent>
</Card>
</Grid>
{/* Notification Settings */}
<Grid item xs={12} md={6}>
<Card>

View File

@@ -14,8 +14,8 @@ interface ApiResponse<T> {
export const vikunjaApi = {
getMyTasks(): Promise<VikunjaTasksResponse> {
return api
.get<ApiResponse<VikunjaTasksResponse['data']>>('/api/vikunja/tasks')
.then((r) => ({ configured: r.data.configured, data: r.data.data }));
.get<ApiResponse<VikunjaTasksResponse['data']> & { vikunjaUrl?: string }>('/api/vikunja/tasks')
.then((r) => ({ configured: r.data.configured, data: r.data.data, vikunjaUrl: r.data.vikunjaUrl }));
},
getOverdueTasks(): Promise<VikunjaTasksResponse> {

View File

@@ -1,9 +1,11 @@
export type BannerLevel = 'info' | 'important' | 'critical';
export type BannerShowAs = 'banner' | 'widget';
export interface Banner {
id: string;
message: string;
level: BannerLevel;
show_as: BannerShowAs;
starts_at: string;
ends_at: string | null;
created_at: string;

View File

@@ -15,6 +15,7 @@ export interface VikunjaProject {
export interface VikunjaTasksResponse {
configured: boolean;
data: VikunjaTask[];
vikunjaUrl?: string;
}
export interface VikunjaProjectsResponse {