From 5aa309b97a37f162bdfb2cc2c973faa60879be98 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Thu, 12 Mar 2026 16:05:01 +0100 Subject: [PATCH] resolve issues with new features --- backend/src/controllers/banner.controller.ts | 1 + backend/src/controllers/vikunja.controller.ts | 4 +- .../migrations/027_add_show_as_to_banners.sql | 3 + backend/src/services/banner.service.ts | 8 +- backend/src/services/bookstack.service.ts | 6 +- .../components/admin/BannerManagementTab.tsx | 32 +- .../src/components/chat/ChatMessageView.tsx | 4 +- frontend/src/components/chat/ChatPanel.tsx | 28 +- .../dashboard/AnnouncementBanner.tsx | 2 +- .../src/components/dashboard/BannerWidget.tsx | 51 +++ .../dashboard/VikunjaMyTasksWidget.tsx | 2 +- .../src/components/dashboard/WidgetGroup.tsx | 49 +++ frontend/src/components/dashboard/index.ts | 2 + frontend/src/pages/Dashboard.tsx | 254 +++++------- frontend/src/pages/FahrzeugBuchungen.tsx | 2 +- frontend/src/pages/FahrzeugForm.tsx | 20 +- frontend/src/pages/Kalender.tsx | 380 +++++++++++++++--- frontend/src/pages/Mitglieder.tsx | 11 + frontend/src/pages/Settings.tsx | 164 +++++++- frontend/src/services/vikunja.ts | 4 +- frontend/src/types/banner.types.ts | 2 + frontend/src/types/vikunja.types.ts | 1 + 22 files changed, 796 insertions(+), 234 deletions(-) create mode 100644 backend/src/database/migrations/027_add_show_as_to_banners.sql create mode 100644 frontend/src/components/dashboard/BannerWidget.tsx create mode 100644 frontend/src/components/dashboard/WidgetGroup.tsx diff --git a/backend/src/controllers/banner.controller.ts b/backend/src/controllers/banner.controller.ts index 75933ae..95992ab 100644 --- a/backend/src/controllers/banner.controller.ts +++ b/backend/src/controllers/banner.controller.ts @@ -6,6 +6,7 @@ import logger from '../utils/logger'; const createSchema = z.object({ message: z.string().min(1).max(2000), level: z.enum(['info', 'important', 'critical']).default('info'), + show_as: z.enum(['banner', 'widget']).default('banner'), starts_at: z.string().datetime().optional(), ends_at: z.string().datetime().nullable().optional(), }); diff --git a/backend/src/controllers/vikunja.controller.ts b/backend/src/controllers/vikunja.controller.ts index 705f807..3978a56 100644 --- a/backend/src/controllers/vikunja.controller.ts +++ b/backend/src/controllers/vikunja.controller.ts @@ -13,7 +13,7 @@ class VikunjaController { } try { 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) { logger.error('VikunjaController.getMyTasks error', { error }); 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) { logger.error('VikunjaController.getOverdueTasks error', { error }); res.status(500).json({ success: false, message: 'Vikunja konnte nicht abgefragt werden' }); diff --git a/backend/src/database/migrations/027_add_show_as_to_banners.sql b/backend/src/database/migrations/027_add_show_as_to_banners.sql new file mode 100644 index 0000000..c06db29 --- /dev/null +++ b/backend/src/database/migrations/027_add_show_as_to_banners.sql @@ -0,0 +1,3 @@ +ALTER TABLE announcement_banners + ADD COLUMN show_as VARCHAR(20) NOT NULL DEFAULT 'banner' + CHECK (show_as IN ('banner', 'widget')); diff --git a/backend/src/services/banner.service.ts b/backend/src/services/banner.service.ts index 0ecb4ec..9b723f0 100644 --- a/backend/src/services/banner.service.ts +++ b/backend/src/services/banner.service.ts @@ -4,6 +4,7 @@ export interface Banner { id: string; message: string; level: 'info' | 'important' | 'critical'; + show_as: 'banner' | 'widget'; starts_at: string; ends_at: string | null; created_by: string | null; @@ -13,6 +14,7 @@ export interface Banner { export interface CreateBannerInput { message: string; level: 'info' | 'important' | 'critical'; + show_as?: 'banner' | 'widget'; starts_at?: string; ends_at?: string | null; } @@ -39,9 +41,9 @@ class BannerService { async create(data: CreateBannerInput, userId: string): Promise { const result = await pool.query( - `INSERT INTO announcement_banners (message, level, starts_at, ends_at, created_by) - VALUES ($1, $2, $3, $4, $5) RETURNING *`, - [data.message, data.level, data.starts_at ?? new Date().toISOString(), data.ends_at ?? null, userId] + `INSERT INTO announcement_banners (message, level, show_as, starts_at, ends_at, created_by) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [data.message, data.level, data.show_as ?? 'banner', data.starts_at ?? new Date().toISOString(), data.ends_at ?? null, userId] ); return result.rows[0]; } diff --git a/backend/src/services/bookstack.service.ts b/backend/src/services/bookstack.service.ts index 5c41a57..94bbe48 100644 --- a/backend/src/services/bookstack.service.ts +++ b/backend/src/services/bookstack.service.ts @@ -98,7 +98,7 @@ async function getRecentPages(): Promise { const pages: BookStackPage[] = response.data?.data ?? []; return pages.map((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) { if (axios.isAxiosError(error)) { @@ -134,7 +134,7 @@ async function searchPages(query: string): Promise { slug: item.slug, book_id: item.book_id ?? 0, 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: '' }, tags: item.tags ?? [], })); @@ -189,7 +189,7 @@ async function getPageById(id: number): Promise { html: page.html ?? '', created_at: page.created_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, createdBy: page.created_by, updatedBy: page.updated_by, diff --git a/frontend/src/components/admin/BannerManagementTab.tsx b/frontend/src/components/admin/BannerManagementTab.tsx index 574d16f..31cda37 100644 --- a/frontend/src/components/admin/BannerManagementTab.tsx +++ b/frontend/src/components/admin/BannerManagementTab.tsx @@ -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 = { info: 'Info', @@ -53,6 +53,11 @@ function formatDateTime(iso: string | null | undefined): string { }); } +const SHOW_AS_LABEL: Record = { + 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('info'); const [newEndsAt, setNewEndsAt] = useState(''); + const [newShowAs, setNewShowAs] = useState('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() { Stufe + Anzeige Nachricht Erstellt am Ablauf @@ -149,6 +159,13 @@ function BannerManagementTab() { size="small" /> + + + {banner.message} {formatDateTime(banner.created_at)} {formatDateTime(banner.ends_at)} @@ -166,7 +183,7 @@ function BannerManagementTab() { ))} {(banners ?? []).length === 0 && ( - Keine Banner vorhanden + Keine Banner vorhanden )} @@ -200,6 +217,17 @@ function BannerManagementTab() { Kritisch + + Anzeige als + + { 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 = () => { - {messages?.map((msg) => ( + {[...(messages ?? [])].reverse().map((msg) => ( { 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 = () => { Chat - setChatPanelOpen(false)} aria-label="Chat einklappen"> - - + + {nextcloudUrl && ( + + safeOpenUrl(`${nextcloudUrl}/apps/spreed`)} aria-label="In Nextcloud öffnen"> + + + + )} + setChatPanelOpen(false)} aria-label="Chat einklappen"> + + + {!connected ? ( - Nextcloud nicht verbunden. Bitte verbinden Sie sich in den Einstellungen. + Nextcloud nicht verbunden. Bitte verbinden Sie sich in den Einstellungen. ) : selectedRoomToken ? ( diff --git a/frontend/src/components/dashboard/AnnouncementBanner.tsx b/frontend/src/components/dashboard/AnnouncementBanner.tsx index 8e77652..9c977f7 100644 --- a/frontend/src/components/dashboard/AnnouncementBanner.tsx +++ b/frontend/src/components/dashboard/AnnouncementBanner.tsx @@ -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 diff --git a/frontend/src/components/dashboard/BannerWidget.tsx b/frontend/src/components/dashboard/BannerWidget.tsx new file mode 100644 index 0000000..67e7385 --- /dev/null +++ b/frontend/src/components/dashboard/BannerWidget.tsx @@ -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 = { + 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 ( + + + + + Mitteilungen + + + {widgetBanners.map(banner => ( + + + {banner.message} + + + ))} + + + + ); +} diff --git a/frontend/src/components/dashboard/VikunjaMyTasksWidget.tsx b/frontend/src/components/dashboard/VikunjaMyTasksWidget.tsx index d63a456..39c34e3 100644 --- a/frontend/src/components/dashboard/VikunjaMyTasksWidget.tsx +++ b/frontend/src/components/dashboard/VikunjaMyTasksWidget.tsx @@ -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 ?? ''} /> ))} diff --git a/frontend/src/components/dashboard/WidgetGroup.tsx b/frontend/src/components/dashboard/WidgetGroup.tsx new file mode 100644 index 0000000..634a57e --- /dev/null +++ b/frontend/src/components/dashboard/WidgetGroup.tsx @@ -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 ( + + + {title} + + + {children} + + + ); +} + +export default WidgetGroup; diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts index 66e4611..3bae2c5 100644 --- a/frontend/src/components/dashboard/index.ts +++ b/frontend/src/components/dashboard/index.ts @@ -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'; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 310f6dd..a3e9ddc 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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() { )} - {/* Vehicle Status Card */} - {widgetVisible('vehicles') && ( - - - - - - - - )} - - {/* Equipment Status Card */} - {widgetVisible('equipment') && ( - - - - - - - - )} - - {/* Atemschutz Status Card */} - {canViewAtemschutz && widgetVisible('atemschutz') && ( - - - - - - - - )} - - {/* Upcoming Events Widget */} - {widgetVisible('events') && ( - - - - - - - - )} - - {/* Nextcloud Talk Widget */} - {widgetVisible('nextcloudTalk') && ( - - {dataLoading ? ( - - ) : ( - - - - - - )} - - )} - - {/* BookStack Recent Pages Widget */} - {widgetVisible('bookstackRecent') && ( - - - - - - - - )} - - {/* BookStack Search Widget */} - {widgetVisible('bookstackSearch') && ( - - - - - - - - )} - - {/* Vikunja — My Tasks Widget */} - {widgetVisible('vikunjaTasks') && ( - - - - - - - - )} - - {/* Vikunja — Quick Add Widget */} - {widgetVisible('vikunjaQuickAdd') && ( - - - - - - - - )} - - {/* Vehicle Booking — Quick Add Widget */} - {canWrite && widgetVisible('vehicleBooking') && ( - - - - - - - - )} - - {/* Event — Quick Add Widget */} - {canWrite && widgetVisible('eventQuickAdd') && ( - - - - - - - - )} - {/* Vikunja — Overdue Notifier (invisible, polling component) */} - {/* Links Widget */} - {widgetVisible('links') && ( - + {/* Status Group */} + + {widgetVisible('vehicles') && ( + + + + + + )} + + {widgetVisible('equipment') && ( + + + + + + )} + + {canViewAtemschutz && widgetVisible('atemschutz') && ( + + + + + + )} + + {isAdmin && widgetVisible('adminStatus') && ( + + + + + + )} + + + {/* Kalender Group */} + + {widgetVisible('events') && ( + + + + + + )} + + {canWrite && widgetVisible('vehicleBooking') && ( + + + + + + )} + + {canWrite && widgetVisible('eventQuickAdd') && ( + + + + + + )} + + + {/* Dienste Group */} + + {widgetVisible('bookstackRecent') && ( + + + + + + )} + + {widgetVisible('bookstackSearch') && ( + + + + + + )} + + {widgetVisible('vikunjaTasks') && ( + + + + + + )} + + {widgetVisible('vikunjaQuickAdd') && ( + + + + + + )} + + + {/* Information Group */} + + {widgetVisible('links') && ( + + + + + + )} + - + - - )} - - {/* Admin Status Widget — only for admins */} - {isAdmin && widgetVisible('adminStatus') && ( - - - - - - - - )} + diff --git a/frontend/src/pages/FahrzeugBuchungen.tsx b/frontend/src/pages/FahrzeugBuchungen.tsx index fbfbd92..4b6fbb2 100644 --- a/frontend/src/pages/FahrzeugBuchungen.tsx +++ b/frontend/src/pages/FahrzeugBuchungen.tsx @@ -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, }} > diff --git a/frontend/src/pages/FahrzeugForm.tsx b/frontend/src/pages/FahrzeugForm.tsx index 15097c7..9bfc279 100644 --- a/frontend/src/pages/FahrzeugForm.tsx +++ b/frontend/src/pages/FahrzeugForm.tsx @@ -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() { setForm((prev) => ({ ...prev, paragraph57a_faellig_am: e.target.value }))} InputLabelProps={{ shrink: true }} @@ -326,7 +328,7 @@ function FahrzeugForm() { setForm((prev) => ({ ...prev, naechste_wartung_am: e.target.value }))} InputLabelProps={{ shrink: true }} diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index 8925b6c..2b3bc07 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -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('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([]); const [veranstaltungen, setVeranstaltungen] = useState([]); @@ -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 */} + + + + + + - {/* Month navigation */} + {/* Navigation */} @@ -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}`} + + ) : ( + + + + + {isConnecting ? ( + + + + Warte auf Bestätigung... + + + ) : ( + + )} + + )} + + + + {/* Notification Settings */} diff --git a/frontend/src/services/vikunja.ts b/frontend/src/services/vikunja.ts index 967041c..f4f6769 100644 --- a/frontend/src/services/vikunja.ts +++ b/frontend/src/services/vikunja.ts @@ -14,8 +14,8 @@ interface ApiResponse { export const vikunjaApi = { getMyTasks(): Promise { return api - .get>('/api/vikunja/tasks') - .then((r) => ({ configured: r.data.configured, data: r.data.data })); + .get & { vikunjaUrl?: string }>('/api/vikunja/tasks') + .then((r) => ({ configured: r.data.configured, data: r.data.data, vikunjaUrl: r.data.vikunjaUrl })); }, getOverdueTasks(): Promise { diff --git a/frontend/src/types/banner.types.ts b/frontend/src/types/banner.types.ts index 0481cd8..f916805 100644 --- a/frontend/src/types/banner.types.ts +++ b/frontend/src/types/banner.types.ts @@ -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; diff --git a/frontend/src/types/vikunja.types.ts b/frontend/src/types/vikunja.types.ts index 855ca95..d5afb5f 100644 --- a/frontend/src/types/vikunja.types.ts +++ b/frontend/src/types/vikunja.types.ts @@ -15,6 +15,7 @@ export interface VikunjaProject { export interface VikunjaTasksResponse { configured: boolean; data: VikunjaTask[]; + vikunjaUrl?: string; } export interface VikunjaProjectsResponse {