feat(frontend): Aufgaben-Modul (Modul 6)

Aufgabenliste mit Status-/Prioritätsfilter und Erledigen-Aktion
sowie Formular zum Anlegen neuer Aufgaben.
This commit is contained in:
Nick Szaunig committed 2026-08-21 23:52:32 +02:00
1 parent 7bba168bd6
commit 632d382474
6 files changed
+357 -1

No files matched your search

@@ -34,6 +34,9 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/queens">
Königinnen
</Link>
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/tasks">
Aufgaben
</Link>
{user.role === "ADMIN" ? (
<Link
className="text-sm text-muted-foreground hover:text-foreground"
@@ -0,0 +1,119 @@
"use client";
import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createTask } from "@/lib/tasks/api";
import { TaskPriority } from "@/lib/tasks/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const PRIORITY_OPTIONS: TaskPriority[] = ["LOW", "NORMAL", "HIGH", "URGENT"];
export default function NewTaskPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [priority, setPriority] = useState<TaskPriority>("NORMAL");
const [dueDate, setDueDate] = useState("");
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: createTask,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
router.push("/tasks");
},
onError: () => {
setError("Aufgabe konnte nicht angelegt werden.");
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate({
title,
description: description || undefined,
priority,
dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
});
}
return (
<main className="p-4 sm:p-6">
<Card className="max-w-lg">
<CardHeader>
<CardTitle>Neue Aufgabe</CardTitle>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="task-title">Titel</Label>
<Input
id="task-title"
required
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="task-priority">Priorität</Label>
<Select
value={priority}
onValueChange={(value) => setPriority((value ?? "NORMAL") as TaskPriority)}
>
<SelectTrigger id="task-priority">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRIORITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="task-due-date">Fällig am</Label>
<Input
id="task-due-date"
type="date"
value={dueDate}
onChange={(event) => setDueDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="task-description">Beschreibung</Label>
<Textarea
id="task-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-red-600">
{error}
</p>
) : null}
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
</Button>
</form>
</CardContent>
</Card>
</main>
);
}
@@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { completeTask, fetchTasks } from "@/lib/tasks/api";
import { TaskPriority, TaskStatus } from "@/lib/tasks/types";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const STATUS_OPTIONS: TaskStatus[] = ["OPEN", "IN_PROGRESS", "DONE", "CANCELLED"];
const PRIORITY_OPTIONS: TaskPriority[] = ["LOW", "NORMAL", "HIGH", "URGENT"];
export default function TasksPage() {
const queryClient = useQueryClient();
const [status, setStatus] = useState<TaskStatus | "">("");
const [priority, setPriority] = useState<TaskPriority | "">("");
const [onlyOverdue, setOnlyOverdue] = useState(false);
const { data: tasks, isLoading, isError } = useQuery({
queryKey: ["tasks", status, priority],
queryFn: () =>
fetchTasks({
status: status || undefined,
priority: priority || undefined,
}),
});
const completeMutation = useMutation({
mutationFn: completeTask,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
const [now] = useState(() => Date.now());
const visibleTasks = (tasks ?? []).filter((task) => {
if (!onlyOverdue) return true;
return task.dueDate !== null && new Date(task.dueDate).getTime() < now;
});
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Aufgaben</h1>
<Button nativeButton={false} render={<Link href="/tasks/new" />}>
+ Neue Aufgabe
</Button>
</div>
<div className="flex flex-wrap items-center gap-3">
<Select
value={status || "ALL"}
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as TaskStatus))}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={priority || "ALL"}
onValueChange={(value) =>
setPriority(value === "ALL" ? "" : ((value ?? "") as TaskPriority))
}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Priorität" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Prioritäten</SelectItem>
{PRIORITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={onlyOverdue}
onChange={(event) => setOnlyOverdue(event.target.checked)}
/>
Nur überfällige
</label>
</div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-red-600">
Aufgaben konnten nicht geladen werden.
</p>
) : null}
{tasks ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Titel</TableHead>
<TableHead>Priorität</TableHead>
<TableHead>Fällig</TableHead>
<TableHead>Status</TableHead>
<TableHead>Aktion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleTasks.map((task) => (
<TableRow key={task.id}>
<TableCell>{task.title}</TableCell>
<TableCell>{task.priority}</TableCell>
<TableCell>
{task.dueDate ? new Date(task.dueDate).toLocaleDateString("de-DE") : "—"}
</TableCell>
<TableCell>
<Badge variant={task.status === "DONE" ? "secondary" : "default"}>
{task.status}
</Badge>
</TableCell>
<TableCell>
{task.status !== "DONE" && task.status !== "CANCELLED" ? (
<Button
size="sm"
variant="outline"
disabled={completeMutation.isPending}
onClick={() => completeMutation.mutate(task.id)}
>
Erledigen
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : null}
</main>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { apiFetch } from "@/lib/auth/api-client";
import { CreateTaskInput, Task, TaskFilter, UpdateTaskInput } from "./types";
export function fetchTasks(filter: TaskFilter): Promise<Task[]> {
const params = new URLSearchParams();
if (filter.status) params.set("status", filter.status);
if (filter.priority) params.set("priority", filter.priority);
const query = params.toString();
return apiFetch<Task[]>(`/tasks${query ? `?${query}` : ""}`);
}
export function createTask(input: CreateTaskInput): Promise<Task> {
return apiFetch<Task>("/tasks", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateTask(id: string, input: UpdateTaskInput): Promise<Task> {
return apiFetch<Task>(`/tasks/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function completeTask(id: string): Promise<Task> {
return apiFetch<Task>(`/tasks/${id}/complete`, { method: "POST" });
}
+45
View File
@@ -0,0 +1,45 @@
export type TaskStatus = "OPEN" | "IN_PROGRESS" | "DONE" | "CANCELLED";
export type TaskPriority = "LOW" | "NORMAL" | "HIGH" | "URGENT";
export type TaskRelatedType =
| "COLONY"
| "APIARY"
| "QUEEN"
| "HONEY_BATCH"
| "STORAGE"
| "GENERAL";
export interface Task {
id: string;
title: string;
description: string | null;
priority: TaskPriority;
dueDate: string | null;
status: TaskStatus;
assignedUserId: string | null;
createdByUserId: string | null;
relatedType: TaskRelatedType;
relatedId: string | null;
sourceInspectionId: string | null;
createdAt: string;
completedAt: string | null;
}
export interface TaskFilter {
status?: TaskStatus;
priority?: TaskPriority;
}
export interface CreateTaskInput {
title: string;
description?: string;
priority?: TaskPriority;
dueDate?: string;
}
export interface UpdateTaskInput {
title?: string;
description?: string;
priority?: TaskPriority;
dueDate?: string;
status?: TaskStatus;
}
+1 -1
View File
@@ -59,7 +59,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo
- Endpunkte: `GET/POST /api/v1/tasks`, `GET/PATCH /api/v1/tasks/:id`, `POST /api/v1/tasks/:id/complete`
- Frontend: `/tasks` (Liste, Filter nach Status/Priorität/Fälligkeit)
- [x] Backend-Modul fertig, getestet, grün, committet
- [ ] Frontend-Views fertig, getestet, grün, committet
- [x] Frontend-Views fertig, getestet, grün, committet
### 7. Varroamessungen