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: "Völker", href: "/colonies" },
|
||||||
{ label: "Königinnen", href: "/queens" },
|
{ label: "Königinnen", href: "/queens" },
|
||||||
{ label: "Aufgaben", href: "/tasks" },
|
{ label: "Aufgaben", href: "/tasks" },
|
||||||
|
{ label: "Kalender", href: "/calendar" },
|
||||||
{ label: "Honigchargen", href: "/honey-batches" },
|
{ label: "Honigchargen", href: "/honey-batches" },
|
||||||
{ label: "Lager", href: "/storage" },
|
{ label: "Lager", href: "/storage" },
|
||||||
{ label: "Suche", href: "/search" },
|
{ 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=`
|
- Endpunkte: `GET /api/v1/calendar?from=&to=&apiaryId=`
|
||||||
- Frontend: `/calendar` (Monats-/Wochenansicht)
|
- Frontend: `/calendar` (Monats-/Wochenansicht)
|
||||||
- [x] Backend-Modul fertig, getestet, grün, committet
|
- [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
|
### 26. Benachrichtigungssystem
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user