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