resolve issues with new features
This commit is contained in:
@@ -6,6 +6,7 @@ import logger from '../utils/logger';
|
|||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
message: z.string().min(1).max(2000),
|
message: z.string().min(1).max(2000),
|
||||||
level: z.enum(['info', 'important', 'critical']).default('info'),
|
level: z.enum(['info', 'important', 'critical']).default('info'),
|
||||||
|
show_as: z.enum(['banner', 'widget']).default('banner'),
|
||||||
starts_at: z.string().datetime().optional(),
|
starts_at: z.string().datetime().optional(),
|
||||||
ends_at: z.string().datetime().nullable().optional(),
|
ends_at: z.string().datetime().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class VikunjaController {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const tasks = await vikunjaService.getMyTasks();
|
const tasks = await vikunjaService.getMyTasks();
|
||||||
res.status(200).json({ success: true, data: tasks, configured: true });
|
res.status(200).json({ success: true, data: tasks, configured: true, vikunjaUrl: environment.vikunja.url });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('VikunjaController.getMyTasks error', { error });
|
logger.error('VikunjaController.getMyTasks error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Vikunja konnte nicht abgefragt werden' });
|
res.status(500).json({ success: false, message: 'Vikunja konnte nicht abgefragt werden' });
|
||||||
@@ -46,7 +46,7 @@ class VikunjaController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({ success: true, data: tasks, configured: true });
|
res.status(200).json({ success: true, data: tasks, configured: true, vikunjaUrl: environment.vikunja.url });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('VikunjaController.getOverdueTasks error', { error });
|
logger.error('VikunjaController.getOverdueTasks error', { error });
|
||||||
res.status(500).json({ success: false, message: 'Vikunja konnte nicht abgefragt werden' });
|
res.status(500).json({ success: false, message: 'Vikunja konnte nicht abgefragt werden' });
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE announcement_banners
|
||||||
|
ADD COLUMN show_as VARCHAR(20) NOT NULL DEFAULT 'banner'
|
||||||
|
CHECK (show_as IN ('banner', 'widget'));
|
||||||
@@ -4,6 +4,7 @@ export interface Banner {
|
|||||||
id: string;
|
id: string;
|
||||||
message: string;
|
message: string;
|
||||||
level: 'info' | 'important' | 'critical';
|
level: 'info' | 'important' | 'critical';
|
||||||
|
show_as: 'banner' | 'widget';
|
||||||
starts_at: string;
|
starts_at: string;
|
||||||
ends_at: string | null;
|
ends_at: string | null;
|
||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
@@ -13,6 +14,7 @@ export interface Banner {
|
|||||||
export interface CreateBannerInput {
|
export interface CreateBannerInput {
|
||||||
message: string;
|
message: string;
|
||||||
level: 'info' | 'important' | 'critical';
|
level: 'info' | 'important' | 'critical';
|
||||||
|
show_as?: 'banner' | 'widget';
|
||||||
starts_at?: string;
|
starts_at?: string;
|
||||||
ends_at?: string | null;
|
ends_at?: string | null;
|
||||||
}
|
}
|
||||||
@@ -39,9 +41,9 @@ class BannerService {
|
|||||||
|
|
||||||
async create(data: CreateBannerInput, userId: string): Promise<Banner> {
|
async create(data: CreateBannerInput, userId: string): Promise<Banner> {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO announcement_banners (message, level, starts_at, ends_at, created_by)
|
`INSERT INTO announcement_banners (message, level, show_as, starts_at, ends_at, created_by)
|
||||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||||
[data.message, data.level, data.starts_at ?? new Date().toISOString(), data.ends_at ?? null, userId]
|
[data.message, data.level, data.show_as ?? 'banner', data.starts_at ?? new Date().toISOString(), data.ends_at ?? null, userId]
|
||||||
);
|
);
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ async function getRecentPages(): Promise<BookStackPage[]> {
|
|||||||
const pages: BookStackPage[] = response.data?.data ?? [];
|
const pages: BookStackPage[] = response.data?.data ?? [];
|
||||||
return pages.map((p) => ({
|
return pages.map((p) => ({
|
||||||
...p,
|
...p,
|
||||||
url: p.url && p.url.startsWith('http') ? p.url : `${bookstack.url}/books/${p.book_slug || p.book_id}/page/${p.slug}`,
|
url: `${bookstack.url}/books/${p.book_slug || p.book_id}/page/${p.slug}`,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
@@ -134,7 +134,7 @@ async function searchPages(query: string): Promise<BookStackSearchResult[]> {
|
|||||||
slug: item.slug,
|
slug: item.slug,
|
||||||
book_id: item.book_id ?? 0,
|
book_id: item.book_id ?? 0,
|
||||||
book_slug: item.book_slug ?? '',
|
book_slug: item.book_slug ?? '',
|
||||||
url: item.url || `${bookstack.url}/books/${item.book_slug || item.book_id}/page/${item.slug}`,
|
url: `${bookstack.url}/books/${item.book_slug || item.book_id}/page/${item.slug}`,
|
||||||
preview_html: item.preview_html ?? { content: '' },
|
preview_html: item.preview_html ?? { content: '' },
|
||||||
tags: item.tags ?? [],
|
tags: item.tags ?? [],
|
||||||
}));
|
}));
|
||||||
@@ -189,7 +189,7 @@ async function getPageById(id: number): Promise<BookStackPageDetail> {
|
|||||||
html: page.html ?? '',
|
html: page.html ?? '',
|
||||||
created_at: page.created_at,
|
created_at: page.created_at,
|
||||||
updated_at: page.updated_at,
|
updated_at: page.updated_at,
|
||||||
url: page.url && page.url.startsWith('http') ? page.url : `${bookstack.url}/books/${page.book_slug || page.book_id}/page/${page.slug}`,
|
url: `${bookstack.url}/books/${page.book_slug || page.book_id}/page/${page.slug}`,
|
||||||
book: page.book,
|
book: page.book,
|
||||||
createdBy: page.created_by,
|
createdBy: page.created_by,
|
||||||
updatedBy: page.updated_by,
|
updatedBy: page.updated_by,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import AddIcon from '@mui/icons-material/Add';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { bannerApi } from '../../services/banners';
|
import { bannerApi } from '../../services/banners';
|
||||||
import { useNotification } from '../../contexts/NotificationContext';
|
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> = {
|
const LEVEL_LABEL: Record<BannerLevel, string> = {
|
||||||
info: 'Info',
|
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() {
|
function BannerManagementTab() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { showSuccess, showError } = useNotification();
|
const { showSuccess, showError } = useNotification();
|
||||||
@@ -60,6 +65,7 @@ function BannerManagementTab() {
|
|||||||
const [newMessage, setNewMessage] = useState('');
|
const [newMessage, setNewMessage] = useState('');
|
||||||
const [newLevel, setNewLevel] = useState<BannerLevel>('info');
|
const [newLevel, setNewLevel] = useState<BannerLevel>('info');
|
||||||
const [newEndsAt, setNewEndsAt] = useState('');
|
const [newEndsAt, setNewEndsAt] = useState('');
|
||||||
|
const [newShowAs, setNewShowAs] = useState<BannerShowAs>('banner');
|
||||||
|
|
||||||
const { data: banners, isLoading } = useQuery({
|
const { data: banners, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'banners'],
|
queryKey: ['admin', 'banners'],
|
||||||
@@ -72,6 +78,7 @@ function BannerManagementTab() {
|
|||||||
bannerApi.create({
|
bannerApi.create({
|
||||||
message: newMessage.trim(),
|
message: newMessage.trim(),
|
||||||
level: newLevel,
|
level: newLevel,
|
||||||
|
show_as: newShowAs,
|
||||||
starts_at: new Date().toISOString(),
|
starts_at: new Date().toISOString(),
|
||||||
ends_at: newEndsAt ? new Date(newEndsAt).toISOString() : null,
|
ends_at: newEndsAt ? new Date(newEndsAt).toISOString() : null,
|
||||||
}),
|
}),
|
||||||
@@ -83,6 +90,7 @@ function BannerManagementTab() {
|
|||||||
setNewMessage('');
|
setNewMessage('');
|
||||||
setNewLevel('info');
|
setNewLevel('info');
|
||||||
setNewEndsAt('');
|
setNewEndsAt('');
|
||||||
|
setNewShowAs('banner');
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
const message = error?.response?.data?.message || 'Banner konnte nicht erstellt werden';
|
const message = error?.response?.data?.message || 'Banner konnte nicht erstellt werden';
|
||||||
@@ -113,6 +121,7 @@ function BannerManagementTab() {
|
|||||||
setNewMessage('');
|
setNewMessage('');
|
||||||
setNewLevel('info');
|
setNewLevel('info');
|
||||||
setNewEndsAt('');
|
setNewEndsAt('');
|
||||||
|
setNewShowAs('banner');
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -133,6 +142,7 @@ function BannerManagementTab() {
|
|||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Stufe</TableCell>
|
<TableCell>Stufe</TableCell>
|
||||||
|
<TableCell>Anzeige</TableCell>
|
||||||
<TableCell>Nachricht</TableCell>
|
<TableCell>Nachricht</TableCell>
|
||||||
<TableCell>Erstellt am</TableCell>
|
<TableCell>Erstellt am</TableCell>
|
||||||
<TableCell>Ablauf</TableCell>
|
<TableCell>Ablauf</TableCell>
|
||||||
@@ -149,6 +159,13 @@ function BannerManagementTab() {
|
|||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={SHOW_AS_LABEL[banner.show_as] ?? 'Banner'}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell sx={{ maxWidth: 400 }}>{banner.message}</TableCell>
|
<TableCell sx={{ maxWidth: 400 }}>{banner.message}</TableCell>
|
||||||
<TableCell>{formatDateTime(banner.created_at)}</TableCell>
|
<TableCell>{formatDateTime(banner.created_at)}</TableCell>
|
||||||
<TableCell>{formatDateTime(banner.ends_at)}</TableCell>
|
<TableCell>{formatDateTime(banner.ends_at)}</TableCell>
|
||||||
@@ -166,7 +183,7 @@ function BannerManagementTab() {
|
|||||||
))}
|
))}
|
||||||
{(banners ?? []).length === 0 && (
|
{(banners ?? []).length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} align="center">Keine Banner vorhanden</TableCell>
|
<TableCell colSpan={6} align="center">Keine Banner vorhanden</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -200,6 +217,17 @@ function BannerManagementTab() {
|
|||||||
<MenuItem value="critical">Kritisch</MenuItem>
|
<MenuItem value="critical">Kritisch</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</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
|
<TextField
|
||||||
margin="dense"
|
margin="dense"
|
||||||
label="Ablaufdatum (optional)"
|
label="Ablaufdatum (optional)"
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const ChatMessageView: React.FC = () => {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
|
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
}, [selectedRoomToken, chatPanelOpen, queryClient]);
|
}, [selectedRoomToken, chatPanelOpen, queryClient, messages?.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
@@ -83,7 +83,7 @@ const ChatMessageView: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ flex: 1, overflow: 'auto', py: 1 }}>
|
<Box sx={{ flex: 1, overflow: 'auto', py: 1 }}>
|
||||||
{messages?.map((msg) => (
|
{[...(messages ?? [])].reverse().map((msg) => (
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
message={msg}
|
message={msg}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Box from '@mui/material/Box';
|
|||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import ChatIcon from '@mui/icons-material/Chat';
|
import ChatIcon from '@mui/icons-material/Chat';
|
||||||
|
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import Avatar from '@mui/material/Avatar';
|
import Avatar from '@mui/material/Avatar';
|
||||||
import Badge from '@mui/material/Badge';
|
import Badge from '@mui/material/Badge';
|
||||||
@@ -12,6 +13,10 @@ import List from '@mui/material/List';
|
|||||||
import ListItem from '@mui/material/ListItem';
|
import ListItem from '@mui/material/ListItem';
|
||||||
import { useLayout } from '../../contexts/LayoutContext';
|
import { useLayout } from '../../contexts/LayoutContext';
|
||||||
import { ChatProvider, useChat } from '../../contexts/ChatContext';
|
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 ChatRoomList from './ChatRoomList';
|
||||||
import ChatMessageView from './ChatMessageView';
|
import ChatMessageView from './ChatMessageView';
|
||||||
|
|
||||||
@@ -22,6 +27,12 @@ const EXPANDED_WIDTH = 360;
|
|||||||
const ChatPanelInner: React.FC = () => {
|
const ChatPanelInner: React.FC = () => {
|
||||||
const { chatPanelOpen, setChatPanelOpen } = useLayout();
|
const { chatPanelOpen, setChatPanelOpen } = useLayout();
|
||||||
const { rooms, selectedRoomToken, selectRoom, connected } = useChat();
|
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) {
|
if (!chatPanelOpen) {
|
||||||
return (
|
return (
|
||||||
@@ -120,15 +131,24 @@ const ChatPanelInner: React.FC = () => {
|
|||||||
<Typography variant="subtitle1" fontWeight={600}>
|
<Typography variant="subtitle1" fontWeight={600}>
|
||||||
Chat
|
Chat
|
||||||
</Typography>
|
</Typography>
|
||||||
<IconButton size="small" onClick={() => setChatPanelOpen(false)} aria-label="Chat einklappen">
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
<ChatIcon fontSize="small" />
|
{nextcloudUrl && (
|
||||||
</IconButton>
|
<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>
|
</Box>
|
||||||
|
|
||||||
{!connected ? (
|
{!connected ? (
|
||||||
<Box sx={{ p: 2 }}>
|
<Box sx={{ p: 2 }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : selectedRoomToken ? (
|
) : selectedRoomToken ? (
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function AnnouncementBanner({ gridColumn }: { gridColumn?: string
|
|||||||
retry: 1,
|
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) => {
|
const handleDismiss = (banner: Banner) => {
|
||||||
if (banner.level === 'critical') return; // never dismiss critical
|
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}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
showDivider={index < tasks.length - 1}
|
showDivider={index < tasks.length - 1}
|
||||||
vikunjaUrl={import.meta.env.VITE_VIKUNJA_URL ?? ''}
|
vikunjaUrl={data?.vikunjaUrl ?? ''}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</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 VehicleBookingQuickAddWidget } from './VehicleBookingQuickAddWidget';
|
||||||
export { default as EventQuickAddWidget } from './EventQuickAddWidget';
|
export { default as EventQuickAddWidget } from './EventQuickAddWidget';
|
||||||
export { default as AnnouncementBanner } from './AnnouncementBanner';
|
export { default as AnnouncementBanner } from './AnnouncementBanner';
|
||||||
|
export { default as BannerWidget } from './BannerWidget';
|
||||||
export { default as LinksWidget } from './LinksWidget';
|
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 DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import SkeletonCard from '../components/shared/SkeletonCard';
|
import SkeletonCard from '../components/shared/SkeletonCard';
|
||||||
import UserProfile from '../components/dashboard/UserProfile';
|
import UserProfile from '../components/dashboard/UserProfile';
|
||||||
import NextcloudTalkWidget from '../components/dashboard/NextcloudTalkWidget';
|
|
||||||
import UpcomingEventsWidget from '../components/dashboard/UpcomingEventsWidget';
|
import UpcomingEventsWidget from '../components/dashboard/UpcomingEventsWidget';
|
||||||
import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard';
|
import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard';
|
||||||
import EquipmentDashboardCard from '../components/equipment/EquipmentDashboardCard';
|
import EquipmentDashboardCard from '../components/equipment/EquipmentDashboardCard';
|
||||||
@@ -24,6 +23,8 @@ import AnnouncementBanner from '../components/dashboard/AnnouncementBanner';
|
|||||||
import VehicleBookingQuickAddWidget from '../components/dashboard/VehicleBookingQuickAddWidget';
|
import VehicleBookingQuickAddWidget from '../components/dashboard/VehicleBookingQuickAddWidget';
|
||||||
import EventQuickAddWidget from '../components/dashboard/EventQuickAddWidget';
|
import EventQuickAddWidget from '../components/dashboard/EventQuickAddWidget';
|
||||||
import LinksWidget from '../components/dashboard/LinksWidget';
|
import LinksWidget from '../components/dashboard/LinksWidget';
|
||||||
|
import BannerWidget from '../components/dashboard/BannerWidget';
|
||||||
|
import WidgetGroup from '../components/dashboard/WidgetGroup';
|
||||||
import { preferencesApi } from '../services/settings';
|
import { preferencesApi } from '../services/settings';
|
||||||
import { WidgetKey } from '../constants/widgets';
|
import { WidgetKey } from '../constants/widgets';
|
||||||
|
|
||||||
@@ -84,155 +85,122 @@ function Dashboard() {
|
|||||||
</Box>
|
</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) */}
|
{/* Vikunja — Overdue Notifier (invisible, polling component) */}
|
||||||
<VikunjaOverdueNotifier />
|
<VikunjaOverdueNotifier />
|
||||||
|
|
||||||
{/* Links Widget */}
|
{/* Status Group */}
|
||||||
{widgetVisible('links') && (
|
<WidgetGroup title="Status" gridColumn="1 / -1">
|
||||||
<Box>
|
{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' }}>
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '790ms' }}>
|
||||||
<Box>
|
<Box>
|
||||||
<LinksWidget />
|
<BannerWidget />
|
||||||
</Box>
|
</Box>
|
||||||
</Fade>
|
</Fade>
|
||||||
</Box>
|
</WidgetGroup>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Admin Status Widget — only for admins */}
|
|
||||||
{isAdmin && widgetVisible('adminStatus') && (
|
|
||||||
<Box>
|
|
||||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '680ms' }}>
|
|
||||||
<Box>
|
|
||||||
<AdminStatusWidget />
|
|
||||||
</Box>
|
|
||||||
</Fade>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ function FahrzeugBuchungen() {
|
|||||||
align="center"
|
align="center"
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: isToday(day) ? 700 : 400,
|
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,
|
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 { useNavigate, useParams } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { vehiclesApi } from '../services/vehicles';
|
import { vehiclesApi } from '../services/vehicles';
|
||||||
import { toGermanDate, fromGermanDate } from '../utils/dateInput';
|
|
||||||
import {
|
import {
|
||||||
FahrzeugStatus,
|
FahrzeugStatus,
|
||||||
FahrzeugStatusLabel,
|
FahrzeugStatusLabel,
|
||||||
@@ -65,9 +64,12 @@ const EMPTY_FORM: FormState = {
|
|||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── 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 {
|
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 ─────────────────────────────────────────────────────────────────
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
@@ -169,8 +171,8 @@ function FahrzeugForm() {
|
|||||||
status_bemerkung: form.status_bemerkung.trim() || undefined,
|
status_bemerkung: form.status_bemerkung.trim() || undefined,
|
||||||
standort: form.standort.trim() || 'Feuerwehrhaus',
|
standort: form.standort.trim() || 'Feuerwehrhaus',
|
||||||
bild_url: form.bild_url.trim() || undefined,
|
bild_url: form.bild_url.trim() || undefined,
|
||||||
paragraph57a_faellig_am: form.paragraph57a_faellig_am ? fromGermanDate(form.paragraph57a_faellig_am) || undefined : undefined,
|
paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined,
|
||||||
naechste_wartung_am: form.naechste_wartung_am ? fromGermanDate(form.naechste_wartung_am) || undefined : undefined,
|
naechste_wartung_am: form.naechste_wartung_am || undefined,
|
||||||
};
|
};
|
||||||
await vehiclesApi.update(id, payload);
|
await vehiclesApi.update(id, payload);
|
||||||
navigate(`/fahrzeuge/${id}`);
|
navigate(`/fahrzeuge/${id}`);
|
||||||
@@ -188,8 +190,8 @@ function FahrzeugForm() {
|
|||||||
status_bemerkung: form.status_bemerkung.trim() || undefined,
|
status_bemerkung: form.status_bemerkung.trim() || undefined,
|
||||||
standort: form.standort.trim() || 'Feuerwehrhaus',
|
standort: form.standort.trim() || 'Feuerwehrhaus',
|
||||||
bild_url: form.bild_url.trim() || undefined,
|
bild_url: form.bild_url.trim() || undefined,
|
||||||
paragraph57a_faellig_am: form.paragraph57a_faellig_am ? fromGermanDate(form.paragraph57a_faellig_am) || undefined : undefined,
|
paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined,
|
||||||
naechste_wartung_am: form.naechste_wartung_am ? fromGermanDate(form.naechste_wartung_am) || undefined : undefined,
|
naechste_wartung_am: form.naechste_wartung_am || undefined,
|
||||||
};
|
};
|
||||||
const newVehicle = await vehiclesApi.create(payload);
|
const newVehicle = await vehiclesApi.create(payload);
|
||||||
navigate(`/fahrzeuge/${newVehicle.id}`);
|
navigate(`/fahrzeuge/${newVehicle.id}`);
|
||||||
@@ -315,7 +317,7 @@ function FahrzeugForm() {
|
|||||||
<TextField
|
<TextField
|
||||||
label="§57a fällig am"
|
label="§57a fällig am"
|
||||||
fullWidth
|
fullWidth
|
||||||
placeholder="TT.MM.JJJJ"
|
type="date"
|
||||||
value={form.paragraph57a_faellig_am}
|
value={form.paragraph57a_faellig_am}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, paragraph57a_faellig_am: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, paragraph57a_faellig_am: e.target.value }))}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
@@ -326,7 +328,7 @@ function FahrzeugForm() {
|
|||||||
<TextField
|
<TextField
|
||||||
label="Nächste Wartung am"
|
label="Nächste Wartung am"
|
||||||
fullWidth
|
fullWidth
|
||||||
placeholder="TT.MM.JJJJ"
|
type="date"
|
||||||
value={form.naechste_wartung_am}
|
value={form.naechste_wartung_am}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, naechste_wartung_am: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, naechste_wartung_am: e.target.value }))}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ import {
|
|||||||
Today as TodayIcon,
|
Today as TodayIcon,
|
||||||
Tune,
|
Tune,
|
||||||
ViewList as ListViewIcon,
|
ViewList as ListViewIcon,
|
||||||
|
ViewDay as ViewDayIcon,
|
||||||
|
ViewWeek as ViewWeekIcon,
|
||||||
Warning,
|
Warning,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -97,6 +99,10 @@ import {
|
|||||||
format as fnsFormat,
|
format as fnsFormat,
|
||||||
startOfWeek,
|
startOfWeek,
|
||||||
endOfWeek,
|
endOfWeek,
|
||||||
|
startOfMonth,
|
||||||
|
endOfMonth,
|
||||||
|
addDays,
|
||||||
|
subDays,
|
||||||
addWeeks,
|
addWeeks,
|
||||||
subWeeks,
|
subWeeks,
|
||||||
eachDayOfInterval,
|
eachDayOfInterval,
|
||||||
@@ -1571,8 +1577,11 @@ export default function Kalender() {
|
|||||||
year: today.getFullYear(),
|
year: today.getFullYear(),
|
||||||
month: today.getMonth(),
|
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 [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 [trainingEvents, setTrainingEvents] = useState<UebungListItem[]>([]);
|
||||||
const [veranstaltungen, setVeranstaltungen] = useState<VeranstaltungListItem[]>([]);
|
const [veranstaltungen, setVeranstaltungen] = useState<VeranstaltungListItem[]>([]);
|
||||||
@@ -1696,23 +1705,37 @@ export default function Kalender() {
|
|||||||
// ── Calendar tab helpers ─────────────────────────────────────────────────────
|
// ── Calendar tab helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handlePrev = () => {
|
const handlePrev = () => {
|
||||||
setViewMonth((prev) => {
|
if (viewMode === 'day') {
|
||||||
const m = prev.month === 0 ? 11 : prev.month - 1;
|
setCurrentDate((d) => subDays(d, 1));
|
||||||
const y = prev.month === 0 ? prev.year - 1 : prev.year;
|
} else if (viewMode === 'week') {
|
||||||
return { year: y, month: m };
|
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 = () => {
|
const handleNext = () => {
|
||||||
setViewMonth((prev) => {
|
if (viewMode === 'day') {
|
||||||
const m = prev.month === 11 ? 0 : prev.month + 1;
|
setCurrentDate((d) => addDays(d, 1));
|
||||||
const y = prev.month === 11 ? prev.year + 1 : prev.year;
|
} else if (viewMode === 'week') {
|
||||||
return { year: y, month: m };
|
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 = () => {
|
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(
|
const handleDayClick = useCallback(
|
||||||
@@ -1742,23 +1765,59 @@ export default function Kalender() {
|
|||||||
});
|
});
|
||||||
}, [veranstaltungen, popoverDay, selectedKategorie]);
|
}, [veranstaltungen, popoverDay, selectedKategorie]);
|
||||||
|
|
||||||
// Filtered lists for list view (current month only)
|
// Filtered lists for list view (filtered by date range)
|
||||||
const trainingForMonth = useMemo(
|
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);
|
const d = new Date(t.datum_von);
|
||||||
return d.getMonth() === viewMonth.month && d.getFullYear() === viewMonth.year;
|
return d >= startOfDay(from) && d <= new Date(to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59);
|
||||||
}),
|
});
|
||||||
[trainingEvents, viewMonth]
|
},
|
||||||
|
[trainingEvents, listFrom, listTo]
|
||||||
);
|
);
|
||||||
|
|
||||||
const eventsForMonth = useMemo(
|
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);
|
const d = new Date(ev.datum_von);
|
||||||
return d.getMonth() === viewMonth.month && d.getFullYear() === viewMonth.year;
|
return d >= startOfDay(from) && d <= new Date(to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59);
|
||||||
}),
|
});
|
||||||
[veranstaltungen, viewMonth]
|
},
|
||||||
|
[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 ─────────────────────────────────────────────────────
|
// ── Veranstaltung cancel ─────────────────────────────────────────────────────
|
||||||
@@ -1991,6 +2050,24 @@ export default function Kalender() {
|
|||||||
>
|
>
|
||||||
{/* View toggle */}
|
{/* View toggle */}
|
||||||
<ButtonGroup size="small" variant="outlined">
|
<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">
|
<Tooltip title="Monatsansicht">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setViewMode('calendar')}
|
onClick={() => setViewMode('calendar')}
|
||||||
@@ -2051,22 +2128,20 @@ export default function Kalender() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* PDF Export — only in list view */}
|
{/* PDF Export — available in all views */}
|
||||||
{viewMode === 'list' && (
|
<Tooltip title="Als PDF exportieren">
|
||||||
<Tooltip title="Als PDF exportieren">
|
<IconButton
|
||||||
<IconButton
|
size="small"
|
||||||
size="small"
|
onClick={() => generatePdf(
|
||||||
onClick={() => generatePdf(
|
viewMonth.year,
|
||||||
viewMonth.year,
|
viewMonth.month,
|
||||||
viewMonth.month,
|
trainingForMonth,
|
||||||
trainingForMonth,
|
eventsForMonth,
|
||||||
eventsForMonth,
|
)}
|
||||||
)}
|
>
|
||||||
>
|
<PdfIcon fontSize="small" />
|
||||||
<PdfIcon fontSize="small" />
|
</IconButton>
|
||||||
</IconButton>
|
</Tooltip>
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* CSV Import */}
|
{/* CSV Import */}
|
||||||
{canWriteEvents && (
|
{canWriteEvents && (
|
||||||
@@ -2091,7 +2166,7 @@ export default function Kalender() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Month navigation */}
|
{/* Navigation */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, gap: 1 }}>
|
||||||
<IconButton onClick={handlePrev} size="small">
|
<IconButton onClick={handlePrev} size="small">
|
||||||
<ChevronLeft />
|
<ChevronLeft />
|
||||||
@@ -2100,7 +2175,11 @@ export default function Kalender() {
|
|||||||
variant="h6"
|
variant="h6"
|
||||||
sx={{ flexGrow: 1, textAlign: 'center', fontWeight: 600 }}
|
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>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -2121,13 +2200,171 @@ export default function Kalender() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Calendar / List body */}
|
{/* Calendar / List / Day / Week body */}
|
||||||
{calLoading ? (
|
{calLoading ? (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
variant="rectangular"
|
variant="rectangular"
|
||||||
height={isMobile ? 320 : 480}
|
height={isMobile ? 320 : 480}
|
||||||
sx={{ borderRadius: 2 }}
|
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' ? (
|
) : viewMode === 'calendar' ? (
|
||||||
<Paper elevation={1} sx={{ p: 1 }}>
|
<Paper elevation={1} sx={{ p: 1 }}>
|
||||||
<MonthCalendar
|
<MonthCalendar
|
||||||
@@ -2140,24 +2377,47 @@ export default function Kalender() {
|
|||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
) : (
|
) : (
|
||||||
<Paper elevation={1} sx={{ px: 1 }}>
|
<>
|
||||||
<CombinedListView
|
{/* Date range inputs for list view */}
|
||||||
trainingEvents={trainingForMonth}
|
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
veranstaltungen={eventsForMonth}
|
<TextField
|
||||||
selectedKategorie={selectedKategorie}
|
type="date"
|
||||||
canWriteEvents={canWriteEvents}
|
label="Von"
|
||||||
onTrainingClick={(id) => navigate(`/training/${id}`)}
|
size="small"
|
||||||
onEventEdit={(ev) => {
|
value={listFrom}
|
||||||
setVeranstEditing(ev);
|
onChange={(e) => setListFrom(e.target.value)}
|
||||||
setVeranstFormOpen(true);
|
InputLabelProps={{ shrink: true }}
|
||||||
}}
|
sx={{ width: 170 }}
|
||||||
onEventCancel={(id) => {
|
/>
|
||||||
setCancelEventId(id);
|
<TextField
|
||||||
setCancelEventGrund('');
|
type="date"
|
||||||
}}
|
label="Bis"
|
||||||
onEventDelete={(id) => setDeleteEventId(id)}
|
size="small"
|
||||||
/>
|
value={listTo}
|
||||||
</Paper>
|
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 */}
|
{/* FAB: Create Veranstaltung */}
|
||||||
|
|||||||
@@ -73,8 +73,19 @@ function useDebounce<T>(value: T, delay: number): T {
|
|||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
function Mitglieder() {
|
function Mitglieder() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
const canWrite = useCanWrite();
|
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 ---
|
// --- data state ---
|
||||||
const [members, setMembers] = useState<MemberListItem[]>([]);
|
const [members, setMembers] = useState<MemberListItem[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -12,17 +13,25 @@ import {
|
|||||||
ToggleButtonGroup,
|
ToggleButtonGroup,
|
||||||
ToggleButton,
|
ToggleButton,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
} from '@mui/material';
|
} 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { useThemeMode } from '../contexts/ThemeContext';
|
import { useThemeMode } from '../contexts/ThemeContext';
|
||||||
import { preferencesApi } from '../services/settings';
|
import { preferencesApi } from '../services/settings';
|
||||||
import { WIDGETS, WidgetKey } from '../constants/widgets';
|
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() {
|
function Settings() {
|
||||||
const { themeMode, setThemeMode } = useThemeMode();
|
const { themeMode, setThemeMode } = useThemeMode();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { showInfo } = useNotification();
|
||||||
|
|
||||||
const { data: preferences, isLoading: prefsLoading } = useQuery({
|
const { data: preferences, isLoading: prefsLoading } = useQuery({
|
||||||
queryKey: ['user-preferences'],
|
queryKey: ['user-preferences'],
|
||||||
@@ -47,6 +56,98 @@ function Settings() {
|
|||||||
mutation.mutate({ ...current, widgets });
|
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 (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
@@ -89,6 +190,67 @@ function Settings() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</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 */}
|
{/* Notification Settings */}
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ interface ApiResponse<T> {
|
|||||||
export const vikunjaApi = {
|
export const vikunjaApi = {
|
||||||
getMyTasks(): Promise<VikunjaTasksResponse> {
|
getMyTasks(): Promise<VikunjaTasksResponse> {
|
||||||
return api
|
return api
|
||||||
.get<ApiResponse<VikunjaTasksResponse['data']>>('/api/vikunja/tasks')
|
.get<ApiResponse<VikunjaTasksResponse['data']> & { vikunjaUrl?: string }>('/api/vikunja/tasks')
|
||||||
.then((r) => ({ configured: r.data.configured, data: r.data.data }));
|
.then((r) => ({ configured: r.data.configured, data: r.data.data, vikunjaUrl: r.data.vikunjaUrl }));
|
||||||
},
|
},
|
||||||
|
|
||||||
getOverdueTasks(): Promise<VikunjaTasksResponse> {
|
getOverdueTasks(): Promise<VikunjaTasksResponse> {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
export type BannerLevel = 'info' | 'important' | 'critical';
|
export type BannerLevel = 'info' | 'important' | 'critical';
|
||||||
|
export type BannerShowAs = 'banner' | 'widget';
|
||||||
|
|
||||||
export interface Banner {
|
export interface Banner {
|
||||||
id: string;
|
id: string;
|
||||||
message: string;
|
message: string;
|
||||||
level: BannerLevel;
|
level: BannerLevel;
|
||||||
|
show_as: BannerShowAs;
|
||||||
starts_at: string;
|
starts_at: string;
|
||||||
ends_at: string | null;
|
ends_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface VikunjaProject {
|
|||||||
export interface VikunjaTasksResponse {
|
export interface VikunjaTasksResponse {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
data: VikunjaTask[];
|
data: VikunjaTask[];
|
||||||
|
vikunjaUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VikunjaProjectsResponse {
|
export interface VikunjaProjectsResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user