resolve issues with new features
This commit is contained in:
@@ -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)"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
51
frontend/src/components/dashboard/BannerWidget.tsx
Normal file
51
frontend/src/components/dashboard/BannerWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
49
frontend/src/components/dashboard/WidgetGroup.tsx
Normal file
49
frontend/src/components/dashboard/WidgetGroup.tsx
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface VikunjaProject {
|
||||
export interface VikunjaTasksResponse {
|
||||
configured: boolean;
|
||||
data: VikunjaTask[];
|
||||
vikunjaUrl?: string;
|
||||
}
|
||||
|
||||
export interface VikunjaProjectsResponse {
|
||||
|
||||
Reference in New Issue
Block a user