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:
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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user