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.
This commit is contained in:
Nick Szaunig committed 2026-08-22 22:39:48 +02:00
1 parent fef1177a2c
commit 9b078022ff
5 files changed
+254 -1

No files matched your search

@@ -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<CalendarEventType, LucideIcon> = {
TASK: ListTodo,
INSPECTION: ClipboardCheck,
TREATMENT: Syringe,
FEEDING: Droplet,
HARVEST: Package,
QUEEN_ASSIGNMENT: Crown,
};
const EVENT_TONE: Record<CalendarEventType, string> = {
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<string, CalendarEvent[]>();
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 (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold tracking-tight">
{month.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
</h1>
<div className="flex items-center gap-2">
<Select
value={apiaryId || "ALL"}
onValueChange={(value) => setApiaryId(value === "ALL" ? "" : (value ?? ""))}
>
<SelectTrigger className="w-44">
<SelectValue placeholder="Alle Bienenstände">
{apiaryId
? (apiaries?.find((apiary) => apiary.id === apiaryId)?.name ?? apiaryId)
: "Alle Bienenstände"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Bienenstände</SelectItem>
{apiaries?.map((apiary) => (
<SelectItem key={apiary.id} value={apiary.id}>
{apiary.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
aria-label="Vorheriger Monat"
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="outline"
size="icon"
aria-label="Nächster Monat"
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
Kalender konnte nicht geladen werden.
</p>
) : null}
<div className="grid grid-cols-7 overflow-hidden rounded-xl border">
{WEEKDAYS.map((weekday) => (
<div
key={weekday}
className="border-b bg-muted px-2 py-1.5 text-center text-xs font-medium text-muted-foreground"
>
{weekday}
</div>
))}
{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 (
<div
key={key}
className={`flex min-h-24 flex-col gap-1 border-r border-b p-1.5 last:border-r-0 ${
isCurrentMonth ? "bg-card" : "bg-muted/40"
}`}
>
<span
className={`text-xs ${isCurrentMonth ? "text-foreground" : "text-muted-foreground"} ${
isToday ? "font-semibold text-primary" : ""
}`}
>
{day.getDate()}
</span>
<div className="flex flex-col gap-1">
{dayEvents.slice(0, 3).map((event) => {
const Icon = EVENT_ICON[event.type];
const href = eventHref(event);
const content = (
<span
className={`flex items-center gap-1 truncate rounded px-1 py-0.5 text-[0.7rem] ${EVENT_TONE[event.type]}`}
>
<Icon className="size-3 shrink-0" />
<span className="truncate">{event.title}</span>
</span>
);
return href ? (
<Link key={event.id} href={href}>
{content}
</Link>
) : (
<div key={event.id}>{content}</div>
);
})}
{dayEvents.length > 3 ? (
<span className="text-[0.7rem] text-muted-foreground">
+{dayEvents.length - 3} weitere
</span>
) : null}
</div>
</div>
);
})}
</div>
</main>
);
}
@@ -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" },
+8
View File
@@ -0,0 +1,8 @@
import { apiFetch } from "@/lib/auth/api-client";
import { CalendarEvent, CalendarQuery } from "./types";
export function fetchCalendarEvents(query: CalendarQuery): Promise<CalendarEvent[]> {
const params = new URLSearchParams({ from: query.from, to: query.to });
if (query.apiaryId) params.set("apiaryId", query.apiaryId);
return apiFetch<CalendarEvent[]>(`/calendar?${params.toString()}`);
}
@@ -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;
}
+1 -1
View File
@@ -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