From 9b078022ffd78f8ecd53411a2a846a29dbb2bbcc Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 22 Aug 2026 22:39:48 +0200 Subject: [PATCH] feat(frontend): Kalender (Modul 25) /calendar Monatsansicht mit Voelkerdaten aus Durchsichten, Aufgaben, Behandlungen, Fuetterungen, Ernten und Koeniginnen-Zuweisungen; Bienenstand-Filter, Monatsnavigation, Klick auf Ereignis fuehrt zum verknuepften Objekt. Nav um Kalender-Link ergaenzt. --- .../src/app/(app)/calendar/page.tsx | 222 ++++++++++++++++++ ApiCore-Frontend/src/app/(app)/layout.tsx | 1 + ApiCore-Frontend/src/lib/calendar/api.ts | 8 + ApiCore-Frontend/src/lib/calendar/types.ts | 22 ++ TASKS.md | 2 +- 5 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 ApiCore-Frontend/src/app/(app)/calendar/page.tsx create mode 100644 ApiCore-Frontend/src/lib/calendar/api.ts create mode 100644 ApiCore-Frontend/src/lib/calendar/types.ts diff --git a/ApiCore-Frontend/src/app/(app)/calendar/page.tsx b/ApiCore-Frontend/src/app/(app)/calendar/page.tsx new file mode 100644 index 0000000..15f8277 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/calendar/page.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { + ChevronLeft, + ChevronRight, + ClipboardCheck, + Crown, + Droplet, + ListTodo, + type LucideIcon, + Package, + Syringe, +} from "lucide-react"; +import { fetchCalendarEvents } from "@/lib/calendar/api"; +import { CalendarEvent, CalendarEventType } from "@/lib/calendar/types"; +import { fetchApiaries } from "@/lib/apiaries/api"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; + +const EVENT_ICON: Record = { + TASK: ListTodo, + INSPECTION: ClipboardCheck, + TREATMENT: Syringe, + FEEDING: Droplet, + HARVEST: Package, + QUEEN_ASSIGNMENT: Crown, +}; + +const EVENT_TONE: Record = { + TASK: "bg-secondary/15 text-secondary", + INSPECTION: "bg-primary/15 text-primary", + TREATMENT: "bg-destructive/15 text-destructive", + FEEDING: "bg-warning/20 text-warning-foreground", + HARVEST: "bg-primary/15 text-primary", + QUEEN_ASSIGNMENT: "bg-secondary/15 text-secondary", +}; + +function eventHref(event: CalendarEvent): string | null { + if (event.relatedType === "Task") return "/tasks"; + if (event.relatedType === "Colony") return `/colonies/${event.relatedId}`; + if (event.relatedType === "Apiary") return `/apiaries/${event.relatedId}`; + if (event.relatedType === "Queen") return `/queens/${event.relatedId}`; + return null; +} + +function toDateKey(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function startOfMonth(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), 1); +} + +function startOfGrid(monthStart: Date): Date { + const day = (monthStart.getDay() + 6) % 7; + const gridStart = new Date(monthStart); + gridStart.setDate(monthStart.getDate() - day); + return gridStart; +} + +export default function CalendarPage() { + const [month, setMonth] = useState(() => startOfMonth(new Date())); + const [apiaryId, setApiaryId] = useState(""); + + const monthStart = startOfMonth(month); + const gridStart = startOfGrid(monthStart); + const gridDays = Array.from({ length: 42 }, (_, index) => { + const date = new Date(gridStart); + date.setDate(gridStart.getDate() + index); + return date; + }); + + const { data: apiaries } = useQuery({ + queryKey: ["apiaries"], + queryFn: () => fetchApiaries(), + }); + const { data: events, isLoading, isError } = useQuery({ + queryKey: ["calendar", monthStart.toISOString(), apiaryId], + queryFn: () => + fetchCalendarEvents({ + from: gridDays[0].toISOString(), + to: gridDays[41].toISOString(), + apiaryId: apiaryId || undefined, + }), + }); + + const eventsByDay = useMemo(() => { + const map = new Map(); + for (const event of events ?? []) { + const key = event.date.slice(0, 10); + const bucket = map.get(key) ?? []; + bucket.push(event); + map.set(key, bucket); + } + return map; + }, [events]); + + return ( +
+
+

+ {month.toLocaleDateString("de-DE", { month: "long", year: "numeric" })} +

+
+ + + +
+
+ + {isLoading ?

Lädt…

: null} + {isError ? ( +

+ Kalender konnte nicht geladen werden. +

+ ) : null} + +
+ {WEEKDAYS.map((weekday) => ( +
+ {weekday} +
+ ))} + {gridDays.map((day) => { + const key = toDateKey(day); + const dayEvents = eventsByDay.get(key) ?? []; + const isCurrentMonth = day.getMonth() === month.getMonth(); + const isToday = key === toDateKey(new Date()); + return ( +
+ + {day.getDate()} + +
+ {dayEvents.slice(0, 3).map((event) => { + const Icon = EVENT_ICON[event.type]; + const href = eventHref(event); + const content = ( + + + {event.title} + + ); + return href ? ( + + {content} + + ) : ( +
{content}
+ ); + })} + {dayEvents.length > 3 ? ( + + +{dayEvents.length - 3} weitere + + ) : null} +
+
+ ); + })} +
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/layout.tsx b/ApiCore-Frontend/src/app/(app)/layout.tsx index e0a0578..62c5b6d 100644 --- a/ApiCore-Frontend/src/app/(app)/layout.tsx +++ b/ApiCore-Frontend/src/app/(app)/layout.tsx @@ -24,6 +24,7 @@ const PRIMARY_LINKS = [ { label: "Völker", href: "/colonies" }, { label: "Königinnen", href: "/queens" }, { label: "Aufgaben", href: "/tasks" }, + { label: "Kalender", href: "/calendar" }, { label: "Honigchargen", href: "/honey-batches" }, { label: "Lager", href: "/storage" }, { label: "Suche", href: "/search" }, diff --git a/ApiCore-Frontend/src/lib/calendar/api.ts b/ApiCore-Frontend/src/lib/calendar/api.ts new file mode 100644 index 0000000..50cdc46 --- /dev/null +++ b/ApiCore-Frontend/src/lib/calendar/api.ts @@ -0,0 +1,8 @@ +import { apiFetch } from "@/lib/auth/api-client"; +import { CalendarEvent, CalendarQuery } from "./types"; + +export function fetchCalendarEvents(query: CalendarQuery): Promise { + const params = new URLSearchParams({ from: query.from, to: query.to }); + if (query.apiaryId) params.set("apiaryId", query.apiaryId); + return apiFetch(`/calendar?${params.toString()}`); +} diff --git a/ApiCore-Frontend/src/lib/calendar/types.ts b/ApiCore-Frontend/src/lib/calendar/types.ts new file mode 100644 index 0000000..f340912 --- /dev/null +++ b/ApiCore-Frontend/src/lib/calendar/types.ts @@ -0,0 +1,22 @@ +export type CalendarEventType = + | "TASK" + | "INSPECTION" + | "TREATMENT" + | "FEEDING" + | "HARVEST" + | "QUEEN_ASSIGNMENT"; + +export interface CalendarEvent { + id: string; + type: CalendarEventType; + date: string; + title: string; + relatedType: string; + relatedId: string; +} + +export interface CalendarQuery { + from: string; + to: string; + apiaryId?: string; +} diff --git a/TASKS.md b/TASKS.md index 90e62f8..ba0cad8 100644 --- a/TASKS.md +++ b/TASKS.md @@ -204,7 +204,7 @@ Reihenfolge = Bau-Reihenfolge. Gleiche Haken-Regel wie MVP: erst setzen, wenn Li - Endpunkte: `GET /api/v1/calendar?from=&to=&apiaryId=` - Frontend: `/calendar` (Monats-/Wochenansicht) - [x] Backend-Modul fertig, getestet, grün, committet -- [ ] Frontend-Views fertig, getestet, grün, committet +- [x] Frontend-Views fertig, getestet, grün, committet ### 26. Benachrichtigungssystem