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
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>
221 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|