Files
nexarch/web/workflow-tasks/app/TaskBoard.tsx
patrickandClaude Sonnet 5 56ab1a2d6d
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (push) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
TSK-04: aufgaben oberflaeche
Neue additive status-Spalte (open/in_progress/done) auf tasks,
status.go (SetStatus/ByStatus/GetStatus/ListAll) haelt completed_at
synchron fuer TSK-03s escalation.Store. Neuer HTTP-Layer taskapi
(GET /tasks, PATCH /tasks/{id}/status), Tenant-Kontext-Durchsetzung
wie backchannelapi (VOR-06). Neue Next.js-Oberflaeche
web/workflow-tasks (Liste + Kanban, Drag&Drop + Tastaturbedienung).
tasks-a11y-fixture fuer den echten Browser-Test, kein Produktions-
binary vorhanden das geaendert werden musste.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 23:26:56 +02:00

221 lines
8.3 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useToast } from "@nexarch/shl";
import { listTasks, setTaskStatus, isOverdue, ApiError, type Task, type TaskStatus, type Priority } from "../lib/api";
const COLUMNS: { key: TaskStatus; label: string }[] = [
{ key: "open", label: "Offen" },
{ key: "in_progress", label: "In Bearbeitung" },
{ key: "done", label: "Erledigt" },
];
type SortKey = "due" | "priority";
const PRIORITY_ORDER: Record<Priority, number> = { high: 0, normal: 1, low: 2 };
// TSK-04: Aufgabenliste (AK1-3) und Kanban-Ansicht (AK4-5) in einer
// gemeinsamen Komponente, beide auf derselben geladenen Aufgabenliste.
// Drag & Drop (HTML5-DnD) UND eine gleichwertige Tastaturbedienung ueber
// "Nach ... verschieben"-Aktionen -- gleiches Barrierefreiheitsmuster wie
// ReminderKanban.tsx (VOR-05).
export function TaskBoard({ userId, tenantSlug }: { userId: string; tenantSlug: string }) {
const [tasks, setTasks] = useState<Task[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [view, setView] = useState<"list" | "kanban">("list");
const [sortKey, setSortKey] = useState<SortKey>("due");
const [priorityFilter, setPriorityFilter] = useState<Priority | "all">("all");
const [overdueOnly, setOverdueOnly] = useState(false);
const [draggingId, setDraggingId] = useState<string | null>(null);
const toast = useToast();
const reload = useCallback(async () => {
try {
const result = await listTasks(userId, tenantSlug);
setTasks(result);
setError(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Aufgaben konnten nicht geladen werden.");
setTasks([]);
}
}, [userId, tenantSlug]);
useEffect(() => {
void reload();
}, [reload]);
async function move(id: string, target: TaskStatus) {
try {
await setTaskStatus(userId, tenantSlug, id, target);
await reload();
if (target === "done") {
toast.push("Aufgabe erledigt.", "success");
}
} catch (err) {
toast.push(err instanceof ApiError ? err.message : "Status konnte nicht geaendert werden.", "danger");
}
}
const filteredSorted = useMemo(() => {
if (!tasks) return [];
let out = tasks;
if (priorityFilter !== "all") {
out = out.filter((t) => t.Priority === priorityFilter);
}
if (overdueOnly) {
out = out.filter((t) => isOverdue(t));
}
out = [...out].sort((a, b) => {
if (sortKey === "priority") {
return PRIORITY_ORDER[a.Priority] - PRIORITY_ORDER[b.Priority];
}
const ad = a.DueDate ? new Date(a.DueDate).getTime() : Number.MAX_SAFE_INTEGER;
const bd = b.DueDate ? new Date(b.DueDate).getTime() : Number.MAX_SAFE_INTEGER;
return ad - bd;
});
return out;
}, [tasks, priorityFilter, overdueOnly, sortKey]);
if (error) {
return <p role="alert">{error}</p>;
}
if (tasks === null) {
return <p>Lade Aufgaben …</p>;
}
return (
<div style={{ padding: 16 }}>
<div style={{ display: "flex", gap: 12, marginBottom: 16, alignItems: "center", flexWrap: "wrap" }}>
<button type="button" onClick={() => setView("list")} aria-pressed={view === "list"}>
Liste
</button>
<button type="button" onClick={() => setView("kanban")} aria-pressed={view === "kanban"}>
Kanban
</button>
{view === "list" && (
<>
<label>
Sortieren nach{" "}
<select value={sortKey} onChange={(e) => setSortKey(e.target.value as SortKey)}>
<option value="due">Faelligkeit</option>
<option value="priority">Prioritaet</option>
</select>
</label>
<label>
Prioritaet{" "}
<select value={priorityFilter} onChange={(e) => setPriorityFilter(e.target.value as Priority | "all")}>
<option value="all">Alle</option>
<option value="high">Hoch</option>
<option value="normal">Normal</option>
<option value="low">Niedrig</option>
</select>
</label>
<label>
<input type="checkbox" checked={overdueOnly} onChange={(e) => setOverdueOnly(e.target.checked)} /> Nur
ueberfaellige
</label>
</>
)}
</div>
{view === "list" ? (
<ul style={{ listStyle: "none", margin: 0, padding: 0 }} data-testid="task-list">
{filteredSorted.map((t) => {
const overdue = isOverdue(t);
return (
<li
key={t.ID}
style={{
border: "1px solid var(--shl-color-border, #d0d5dd)",
borderRadius: 6,
padding: 8,
marginBottom: 8,
background: overdue ? "var(--shl-color-danger-bg, #fef2f2)" : "var(--shl-color-background, #ffffff)",
}}
>
<p style={{ margin: "0 0 4px", fontWeight: 600 }}>
{t.Title} {overdue && <span style={{ color: "#B00020" }}>(ueberfaellig)</span>}
</p>
<p style={{ margin: 0, fontSize: 14 }}>
Prioritaet: {t.Priority} {t.DueDate ? `· Faellig: ${new Date(t.DueDate).toLocaleDateString("de-DE")}` : ""}
</p>
</li>
);
})}
</ul>
) : (
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
{COLUMNS.map((col) => {
const columnTasks = tasks.filter((t) => t.Status === col.key);
return (
<div
key={col.key}
role="group"
aria-label={`Spalte ${col.label}`}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const id = e.dataTransfer.getData("text/plain") || draggingId;
if (id) {
void move(id, col.key);
}
setDraggingId(null);
}}
style={{
flex: 1,
minWidth: 200,
border: "1px solid var(--shl-color-border, #d0d5dd)",
borderRadius: 8,
padding: 12,
minHeight: 120,
background: "var(--shl-color-surface, #f9fafb)",
}}
>
<h3 style={{ marginTop: 0 }}>{col.label}</h3>
{columnTasks.length === 0 ? (
<p style={{ color: "var(--shl-color-text-secondary, #475467)" }}>Keine Aufgaben.</p>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
{columnTasks.map((t) => (
<li
key={t.ID}
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", t.ID);
setDraggingId(t.ID);
}}
onDragEnd={() => setDraggingId(null)}
style={{
border: "1px solid var(--shl-color-border, #d0d5dd)",
borderRadius: 6,
padding: 8,
marginBottom: 8,
background: "var(--shl-color-background, #ffffff)",
}}
>
<p style={{ margin: "0 0 4px", fontWeight: 600 }}>{t.Title}</p>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{COLUMNS.filter((c) => c.key !== col.key).map((target) => (
<button
key={target.key}
type="button"
onClick={() => void move(t.ID, target.key)}
aria-label={`${t.Title} nach ${target.label} verschieben`}
>
→ {target.label}
</button>
))}
</div>
</li>
))}
</ul>
)}
</div>
);
})}
</div>
)}
</div>
);
}