Redesign workspace with shadcn workflows and complete group management

This commit is contained in:
Weapie 2026-10-06 13:43:01 +02:00
parent 813ee735c6
commit 94d29850c6
23 changed files with 4733 additions and 1842 deletions

View File

@ -74,3 +74,7 @@ Webdienst mit schreibgeschütztem Root-Dateisystem, entferntem Capability-Satz,
`scripts/start-demo.ps1` baut und startet die isolierte Demo reproduzierbar und bewahrt vorhandene Daten. `scripts/backup.ps1` erstellt ein konsistentes DB-/Datei-Backup mit Prüfsummen und startet Dienste auch nach Fehlern wieder. Restore-Übung: 3 Konten, 6 Aufgaben und 1 Dateieintrag in separater Datenbank wiederhergestellt; SHA-256 des separat entpackten PDFs stimmt mit dem Original überein.
Docker-Demo geprüft: Anmeldung, private PDF-Speicherung/-Download, Aufgaben- und Rechteoberfläche ohne Browserfehler. Kleiner Last-Smoke-Test: 50 autorisierte Listenabfragen, Parallelität 5, Median 43 ms, p95 55 ms auf diesem Rechner. Kein Kapazitätsnachweis für produktive Nutzerzahlen. PostgreSQL, Web und Worker melden healthy. nginx-HTTPS-Beispiel ist eine Vorlage; öffentliche Domain/Zertifikate und echte externe Dienste sind noch nicht konfiguriert.
## Überarbeitung der Oberfläche (06.10.2026)
Die Arbeitsoberfläche nutzt durchgehend shadcn/Radix-Dialoge, Sheets, Tabs, Tabellen, Auswahlfelder, Bestätigungen und Lucide-Icons. Aufgaben, Detailansicht, Benutzer, Gruppen/Rollen und Integrationen wurden überarbeitet; Gruppen-/Rollenlöschung ist mit serverseitigen Schutzregeln ergänzt. Die Prüfergebnisse und der erhaltene Funktionsumfang stehen in [UX-ABNAHME.md](UX-ABNAHME.md). Die aktualisierten Produktionsimages laufen weiterhin in der isolierten lokalen Demo.

24
UX-ABNAHME.md Normal file
View File

@ -0,0 +1,24 @@
# Überarbeitung der Bedienoberfläche
Funktionsbestand, der in der überarbeiteten Oberfläche erhalten und geprüft wird:
- Aufgaben: Suche, Status/Projekt/Tag/Archiv, gespeicherte Filter, Pagination, Liste/Kanban/Kalender, Erstellen/Bearbeiten, Stapelaktionen, CSV/XLSX/ICS.
- Aufgabendetails: Status, Übernahme, Archiv/Wiederherstellung/Löschen, Vorlagen, Checkliste, Unteraufgaben/Abhängigkeiten, Upload/Download/Löschen, Kommentare und Historie.
- Gruppen: Erstellen, Umbenennen, Aktivieren/Deaktivieren, Mitglieder, Rollen, Löschen mit Bestätigung; Gruppen mit Aufgaben bleiben geschützt.
- Rollen: Erstellen/Bearbeiten, verständliche Berechtigungsauswahl, Zuweisung, Entfernen; Systemrollen und letzter Administrator bleiben geschützt.
- Benutzer: Suche, Anlegen/Bearbeiten, Status, Einladungen, OIDC-Verknüpfung, Mitgliedschaften, direkte Rollen und effektive Rechte.
- Integrationen: Importvorschau/-ausführung, Kalenderlinks, API-Tokens, Webhooks/Zustellstatus, Audit-Pagination.
- Konto, Anmeldung, Dunkelmodus, Tastaturbedienung, Lade-/Leer-/Fehlerzustände und mobile Ansichten.
Komponentenbasis: bestehende shadcn/Radix-Komponenten; Dialog, Alert Dialog, Sheet, Tabs, Dropdown Menu, Select, Popover, Table, Badge, Avatar, Skeleton, Checkbox, Card, Button, Input, Textarea und Sonner. Lucide-Icons unterstützen Aktionen und Status. Keine statischen Ersatzdaten für echte Funktionen.
## Prüfergebnis vom 06.10.2026
- Produktionsbuild erfolgreich; ESLint ohne Fehler/Warnungen; 13 Sicherheitstests bestanden.
- 20 Browser-/API-Szenarien bestanden (19 im Gesamtlauf, Import nach Korrektur seines Button-Selektors separat): bestehende 15 Sicherheits-/Funktionsszenarien plus Gruppen-/Rollen-Lebenszyklus, Löschschutz, Aufgabenformular/Kommentar/Checkliste, Benutzerverwaltung und Importvorschau/-ausführung.
- Gruppen-/Rollenprüfung verwendet echte Datenbankeinträge: anlegen, umbenennen, Mitglied hinzufügen, Rolle zuweisen, Löschung abbrechen/bestätigen, abhängige Zuweisungen prüfen. Belegte Gruppen und geschützte Systemrollen werden serverseitig nicht gelöscht.
- Mobile Aufgabenformulare halten die Aktionen sichtbar; Felder scrollen innerhalb des Dialogs. Desktop, 390-px-Mobilansicht und Dunkelmodus anhand realer Screenshots kontrolliert. Dialoganimationen für Screenshots deaktiviert.
- Laufende Docker-Demo auf http://localhost:3110 aktualisiert. Web, Datenbank und Worker sind healthy. Bestehende Demodaten bleiben erhalten. Keine Veröffentlichung auf dem Git-Remote.
- Screenshots und Messwerte erzeugt `scripts/check-demo.ts` unter `test-results/` (ignorierte Prüfarbeitsdateien). Keine Browser-JavaScriptfehler beim Durchgang durch Aufgaben, Gruppen, Übersicht, Details, Benutzer, Einstellungen und Kalender.
Die Kalenderseite ist eine nach Fälligkeit gruppierte Monatsagenda. Externe SMTP-/SSO-/Webhook-Dienste sind in der lokalen Demo nicht eingerichtet; produktive Zustellung bzw. Anbieter-Anmeldung wird dadurch nicht behauptet. Infrastrukturgrenzen stehen weiterhin in IMPLEMENTATION.md und SECURITY.md.

112
package-lock.json generated
View File

@ -13,6 +13,7 @@
"@prisma/client": "6.19.3",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-icons": "^1.3.2",
@ -2229,6 +2230,117 @@
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.3.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.12.tgz",
"integrity": "sha512-gNOS2lLrpQxNLWHhCX0sayfVRiyB5gQp7nQIzpl+PM1Hkab52utsKtD27alAr78uqhpvX51kwqDp71hM5hNSwg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-context": "1.2.2",
"@radix-ui/react-presence": "1.1.11",
"@radix-ui/react-primitive": "2.1.11",
"@radix-ui/react-use-controllable-state": "1.2.6",
"@radix-ui/react-use-size": "1.1.5"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-presence": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz",
"integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": {
"version": "2.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.11.tgz",
"integrity": "sha512-RzcaSw5aKl0OPhBJ1wbolKSF6nVvN+0o/QLeIVq+09npiPdnCkPggy2k6gI6yetxl4ZyrlBPYFgCB8BQJZwXcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.4.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.4.0.tgz",
"integrity": "sha512-IuMVHZ+Ah5fkctu3X3qeFrV/ZrBcuWuDTFUUPfS/L5hvoTjzhp/HdWGE14CsQombPdEidsa33HVlYZXi49PZ1Q==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-size": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.5.tgz",
"integrity": "sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz",

View File

@ -24,6 +24,7 @@
"@prisma/client": "6.19.3",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-icons": "^1.3.2",

View File

@ -101,20 +101,43 @@ async function main() {
await mkdir("test-results", { recursive: true });
await page.screenshot({
path: "test-results/demo-desktop.png",
fullPage: true,
fullPage: true, animations: "disabled",
});
await page.goto(`${baseURL}/dashboard/access`);
await page
.getByRole("heading", { name: "Gruppen, Rollen und Permissions" })
.waitFor();
await page
.getByRole("option", { name: "Systemadministrator", exact: true })
.first()
.waitFor({ state: "attached" });
await page.screenshot({
path: "test-results/demo-rbac.png",
fullPage: true,
});
await page.getByRole("heading", { name: "Gruppen & Rechte" }).waitFor();
await page.getByRole("button", { name: "Haustechnik", exact: true }).waitFor();
await page.screenshot({ path: "test-results/demo-rbac.png", fullPage: true, animations: "disabled" });
await page.getByRole("button", { name: "Haustechnik", exact: true }).click();
await page.getByRole("dialog", { name: "Haustechnik", exact: true }).waitFor();
await page.screenshot({ path: "test-results/demo-group-detail.png", fullPage: true, animations: "disabled" });
await page.getByRole("dialog", { name: "Haustechnik", exact: true }).getByRole("button", { name: "Schließen" }).click();
for (const [route, heading] of [
["/dashboard", "Übersicht"],
["/dashboard/tasks/demo-task-1", "Heizungsanlage prüfen"],
["/dashboard/users", "Benutzer"],
["/dashboard/settings", "Import & Integrationen"],
["/dashboard/calendar", "Kalender"],
]) {
await page.goto(`${baseURL}${route}`);
if (route !== "/dashboard") await page.getByRole("heading", { name: heading, exact: true }).waitFor();
await page.waitForLoadState("networkidle");
await page.screenshot({ path: `test-results/demo-${route.split("/").pop() || "overview"}.png`, fullPage: true, animations: "disabled" });
}
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`${baseURL}/dashboard/tasks`);
await page.getByRole("link", { name: "Heizungsanlage prüfen", exact: true }).waitFor();
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
await page.screenshot({ path: "test-results/demo-mobile.png", fullPage: true, animations: "disabled" });
await page.getByRole("button", { name: "Neue Aufgabe", exact: true }).click();
await page.getByRole("dialog", { name: "Neue Aufgabe", exact: true }).waitFor();
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
await page.screenshot({ path: "test-results/demo-mobile-dialog.png", fullPage: false, animations: "disabled" });
await page.getByRole("dialog", { name: "Neue Aufgabe", exact: true }).getByRole("button", { name: "Abbrechen" }).click();
await page.setViewportSize({ width: 1440, height: 1000 });
await page.emulateMedia({ colorScheme: "dark" });
await page.reload();
await page.getByRole("link", { name: "Heizungsanlage prüfen", exact: true }).waitFor();
await page.screenshot({ path: "test-results/demo-dark.png", fullPage: true, animations: "disabled" });
assert.deepEqual(errors, []);
const result = {
requests: durations.length,

View File

@ -2,11 +2,13 @@
import { useEffect, useState } from "react";
import { useSession, signOut, signIn, getProviders } from "next-auth/react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { KeyRound } from "lucide-react";
import {
request,
Field,
inputClass,
buttonClass,
panelClass,
} from "@/components/workspace/common";
export default function Account() {
@ -23,8 +25,16 @@ export default function Account() {
getProviders().then((p) => setSso(!!p?.oidc));
}, []);
return (
<main className="mx-auto max-w-lg p-6 space-y-5">
<h1 className="text-3xl font-semibold">Benutzerkonto</h1>
<main className="mx-auto max-w-lg px-6 py-14 space-y-6">
<div className="mb-6">
<div className="mb-4 inline-flex rounded-xl bg-primary/10 p-3 text-primary">
<KeyRound className="size-6" />
</div>
<h1 className="text-2xl font-semibold tracking-tight">Mein Konto</h1>
<p className="mt-2 text-sm text-muted-foreground">
Passwort verwalten und den Zugang zu Ihrem Konto sichern.
</p>
</div>
{message && <p role="status">{message}</p>}
{session?.user?.mustChangePassword && (
<p>
@ -70,7 +80,7 @@ export default function Account() {
<>
{!token && (
<Field label="Aktuelles Passwort">
<input
<Input
required
name="currentPassword"
type="password"
@ -80,7 +90,7 @@ export default function Account() {
</Field>
)}
<Field label="Neues Passwort (mindestens 12 Zeichen)">
<input
<Input
required
name="password"
type="password"
@ -90,33 +100,33 @@ export default function Account() {
className={inputClass}
/>
</Field>
<button disabled={busy} className={buttonClass}>
<Button disabled={busy} className="w-full">
Passwort setzen
</button>
</Button>
</>
) : (
<>
<Field label="E-Mail">
<input
<Input
required
type="email"
name="email"
className={inputClass}
/>
</Field>
<button disabled={busy} className={buttonClass}>
<Button disabled={busy} className="w-full">
Passwortlink anfordern
</button>
</Button>
</>
)}
</form>
{sso && (
<button
className={buttonClass}
<Button
className="w-full"
onClick={() => signIn("oidc", { callbackUrl: "/dashboard" })}
>
Mit Single Sign-on anmelden
</button>
</Button>
)}
<div className="flex gap-3">
<Link className="underline" href="/login">

View File

@ -7,6 +7,8 @@ import { PERMISSIONS } from "@/lib/rbac";
import { adminTransaction } from "@/lib/admin";
import { audit } from "@/lib/audit";
const command = z.discriminatedUnion("action", [
z.object({ action: z.literal("deleteGroup"), id: z.string() }),
z.object({ action: z.literal("deleteRole"), id: z.string() }),
z.object({
action: z.literal("group"),
id: z.string().optional(),
@ -46,7 +48,11 @@ export const GET = api(async (req: NextRequest) => {
requirePermission(user, "roles.assign");
const [groups, roles, users] = await Promise.all([
prisma.group.findMany({
include: { members: true, roles: true },
include: {
members: true,
roles: true,
_count: { select: { tasks: true } },
},
orderBy: { name: "asc" },
}),
prisma.accessRole.findMany({
@ -71,13 +77,46 @@ export const POST = api(async (req: NextRequest) => {
const user = await authenticate(req);
const data = command.parse(await json(req));
requirePermission(user, "roles.assign");
if (data.action === "role") requirePermission(user, "roles.manage");
if (data.action === "group") requirePermission(user, "groups.manage");
if (data.action === "role" || data.action === "deleteRole")
requirePermission(user, "roles.manage");
if (data.action === "group" || data.action === "deleteGroup")
requirePermission(user, "groups.manage");
if (data.action === "membership")
requirePermission(user, "groups.members.manage");
const result = await adminTransaction(async (tx) => {
let target = "";
if (data.action === "group") {
if (data.action === "deleteGroup") {
if (data.id === "legacy")
throw new HttpError(
409,
"Die Bestandsgruppe wird für Migrationen benötigt und kann nicht gelöscht werden.",
);
if (await tx.task.count({ where: { owningGroupId: data.id } }))
throw new HttpError(
409,
"Dieser Gruppe gehören noch Aufgaben. Ordnen Sie diese zuerst einer anderen Gruppe zu oder deaktivieren Sie die Gruppe.",
);
await tx.userRole.deleteMany({
where: { scope: "GROUP", targetGroupId: data.id },
});
await tx.group.delete({ where: { id: data.id } });
target = data.id;
} else if (data.action === "deleteRole") {
const role = await tx.accessRole.findUniqueOrThrow({
where: { id: data.id },
});
if (role.protected) throw new HttpError(403, "Systemrolle ist geschützt");
if (
(await tx.userRole.count({ where: { roleId: data.id } })) ||
(await tx.groupRole.count({ where: { roleId: data.id } }))
)
throw new HttpError(
409,
"Die Rolle ist noch zugewiesen. Entfernen Sie zuerst ihre Zuweisungen.",
);
await tx.accessRole.delete({ where: { id: data.id } });
target = data.id;
} else if (data.action === "group") {
const g = data.id
? await tx.group.update({
where: { id: data.id },

View File

@ -30,7 +30,7 @@ export default async function DashboardLayout({ children }: DashboardLayoutProps
<AppSidebar />
<SidebarInset>
<Header />
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6 pt-6">
<div className="mx-auto flex w-full max-w-[1600px] flex-1 flex-col gap-4 p-4 md:p-8">
{children}
</div>
</SidebarInset>

View File

@ -78,6 +78,9 @@
@apply border-border;
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground antialiased;
font-family: "Inter", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
}
button, input, select, textarea { font: inherit; }
::selection { background: hsl(var(--primary) / 0.15); }
}

View File

@ -1,19 +1,20 @@
"use client";
import {
Home,
LogOut,
LayoutDashboard,
ListTodo,
CalendarDays,
Users,
User,
ChevronUp,
ClipboardList,
Calendar,
ShieldCheck,
Plug,
LogOut,
UserRound,
ChevronsUpDown,
CheckSquare2,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSession, signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import { allowed } from "@/lib/rbac";
import {
Sidebar,
SidebarContent,
@ -30,161 +31,152 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { canManageUsers } from "@/lib/permissions";
/**
* Navigation Item Type
*/
interface NavItem {
title: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
requiresPermission?: (user: import("@/lib/rbac").Principal) => boolean;
}
/**
* Navigation Items Definition
*/
const navItems: NavItem[] = [
{ title: "Übersicht", href: "/dashboard", icon: Home },
{ title: "Gruppen und Rechte", href: "/dashboard/access", icon: Users, requiresPermission: u => u.grants.some(g => g.permission === "roles.assign" && g.scope === "GLOBAL") },
{ title: "Import und Integrationen", href: "/dashboard/settings", icon: ClipboardList },
{ title: "Mein Konto", href: "/account", icon: User },
{
title: "Aufgaben",
href: "/dashboard/tasks",
icon: ClipboardList,
},
{
title: "Kalender",
href: "/dashboard/calendar",
icon: Calendar,
},
{
title: "Benutzerverwaltung",
href: "/dashboard/users",
icon: Users,
requiresPermission: (user) => canManageUsers(user),
},
];
/**
* App Sidebar Component
* Moderne Sidebar mit shadcn/ui Sidebar Komponenten
*/
export function AppSidebar() {
const pathname = usePathname();
const { data: session } = useSession();
const router = useRouter();
/**
* Logout Handler
*/
const handleLogout = async () => {
await signOut({ redirect: false });
router.push("/login");
};
/**
* Initialen für Avatar generieren
*/
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
// Filtere Navigation Items nach Berechtigung
const filteredNavItems = navItems.filter((item) => {
if (!item.requiresPermission) return true;
if (!session?.user?.role) return false;
return item.requiresPermission(session.user);
});
const user = session?.user;
const groups = [
{
name: "Arbeitsbereich",
items: [
{
title: "Übersicht",
href: "/dashboard",
icon: LayoutDashboard,
show: true,
},
{
title: "Aufgaben",
href: "/dashboard/tasks",
icon: ListTodo,
show: true,
},
{
title: "Kalender",
href: "/dashboard/calendar",
icon: CalendarDays,
show: true,
},
],
},
{
name: "Verwaltung",
items: [
{
title: "Benutzer",
href: "/dashboard/users",
icon: Users,
show: allowed(user, "users.read"),
},
{
title: "Gruppen & Rechte",
href: "/dashboard/access",
icon: ShieldCheck,
show: allowed(user, "roles.assign"),
},
{
title: "Import & Integrationen",
href: "/dashboard/settings",
icon: Plug,
show: true,
},
],
},
];
return (
<Sidebar variant="inset">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link href="/dashboard">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<Home className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Aufgabenplaner</span>
<span className="truncate text-xs">Task Management</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<SidebarHeader className="px-3 py-5">
<Link href="/dashboard" className="flex items-center gap-3 px-2">
<span className="flex size-9 items-center justify-center rounded-xl bg-primary text-primary-foreground">
<CheckSquare2 className="size-5" />
</span>
<span>
<span className="block text-sm font-semibold tracking-tight">
Aufgabenplaner
</span>
<span className="text-xs text-muted-foreground">
Gemeinsam erledigen
</span>
</span>
</Link>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{filteredNavItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href || (item.href !== "/dashboard" && pathname.startsWith(item.href + "/"));
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.href}>
<Icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{groups.map((group) => (
<SidebarGroup key={group.name} className="px-3">
<SidebarGroupLabel className="mb-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{group.name}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="gap-1">
{group.items
.filter((i) => i.show)
.map((item) => (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
asChild
isActive={
pathname === item.href ||
(item.href !== "/dashboard" &&
pathname.startsWith(item.href + "/"))
}
className="h-10 px-3 data-[active=true]:bg-primary/10 data-[active=true]:text-primary"
>
<Link href={item.href}>
<item.icon className="size-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarFallback className="rounded-lg">
{session?.user?.name ? getInitials(session.user.name) : "U"}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{session?.user?.name}</span>
<span className="truncate text-xs">{session?.user?.email}</span>
</div>
<ChevronUp className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
side="bottom"
align="end"
sideOffset={4}
>
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
<span>Abmelden</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<SidebarFooter className="border-t p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton size="lg">
<Avatar className="size-8">
<AvatarFallback className="bg-primary/10 text-xs text-primary">
{user?.name
?.split(" ")
.slice(0, 2)
.map((n) => n[0])
.join("") || "?"}
</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{user?.name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{user?.email}
</span>
</span>
<ChevronsUpDown className="size-4 text-muted-foreground" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="w-56">
<DropdownMenuItem asChild>
<Link href="/account">
<UserRound className="mr-2 size-4" />
Mein Konto
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => signOut({ callbackUrl: "/login" })}
>
<LogOut className="mr-2 size-4" />
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarFooter>
</Sidebar>
);

View File

@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}

View File

@ -0,0 +1,27 @@
"use client";
import * as React from "react";
import * as Primitive from "@radix-ui/react-checkbox";
import { Check, Minus } from "lucide-react";
import { cn } from "@/lib/utils";
export const Checkbox = React.forwardRef<
React.ElementRef<typeof Primitive.Root>,
React.ComponentPropsWithoutRef<typeof Primitive.Root>
>(({ className, ...props }, ref) => (
<Primitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded border border-input bg-background shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground",
className,
)}
{...props}
>
<Primitive.Indicator className="flex items-center justify-center">
{props.checked === "indeterminate" ? (
<Minus className="size-3" />
) : (
<Check className="size-3" />
)}
</Primitive.Indicator>
</Primitive.Root>
));
Checkbox.displayName = "Checkbox";

View File

@ -20,7 +20,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@ -37,7 +37,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
@ -45,7 +45,7 @@ const DialogContent = React.forwardRef<
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
<span className="sr-only">Schließen</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>

View File

@ -20,7 +20,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@ -65,7 +65,7 @@ const SheetContent = React.forwardRef<
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
<span className="sr-only">Schließen</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,29 @@
"use client";
import Link from "next/link";
import { useData, Notice, panelClass, statuses } from "./common";
import {
ArrowRight,
Bell,
Circle,
Clock3,
CheckCircle2,
AlertCircle,
Layers3,
Timer,
Check,
} from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { request, useData, Notice, type Me } from "./common";
import { PageHeader, EmptyState, StatusBadge, priorityNames } from "./ui";
type Stats = {
statuses: { status: string; _count: number }[];
overdue: number;
@ -12,86 +35,231 @@ type Stats = {
};
export function Dashboard() {
const stats = useData<Stats>("/api/workspace?mode=stats");
const me = useData<Me>("/api/me");
const qc = useQueryClient();
const notifications = useData<
{ id: string; text: string; taskId: string; read: boolean }[]
{ id: string; text: string; taskId: string; read: boolean; createdAt: string }[]
>("/api/workspace?mode=notifications");
const total = stats.data?.statuses.reduce((s, t) => s + t._count, 0) || 0;
return (
<div className="space-y-5">
<h1 className="text-3xl font-semibold">Übersicht und Berichte</h1>
<div className="space-y-6">
<PageHeader
title="Übersicht"
description={
me.data
? `Willkommen, ${me.data.name}. Hier sehen Sie, was ansteht.`
: "Ihr Aufgabenbestand auf einen Blick."
}
>
<Button variant="outline" asChild>
<Link href="/dashboard/tasks">
Alle Aufgaben
<ArrowRight className="ml-2 size-4" />
</Link>
</Button>
</PageHeader>
<Notice error={stats.error || notifications.error} />
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{Object.entries(statuses).map(([s, l]) => (
<section key={s} className={panelClass}>
<h2>{l}</h2>
<p className="text-3xl font-semibold">
{stats.data?.statuses.find((t) => t.status === s)?._count ?? 0}
</p>
</section>
))}
<section className={panelClass}>
<h2>Überfällig</h2>
<p className="text-3xl text-destructive">
{stats.data?.overdue ?? 0}
</p>
</section>
</div>
<div className="grid gap-4 md:grid-cols-2">
<section className={panelClass}>
<h2 className="font-semibold">Neueste Aufgaben</h2>
{stats.data?.recent.map((t) => (
<p key={t.id}>
<Link className="underline" href={`/dashboard/tasks/${t.id}`}>
{t.was}
</Link>
</p>
))}
</section>
<section className={panelClass}>
<h2 className="font-semibold">Erinnerungen</h2>
{notifications.data?.length ? (
notifications.data.map((n) => (
<p key={n.id}>
<Link
className="underline"
href={`/dashboard/tasks/${n.taskId}`}
{[
{ key: "UNERLEDIGT", label: "Offen", icon: Circle },
{ key: "IN_BEARBEITUNG", label: "In Arbeit", icon: Clock3 },
{ key: "ERLEDIGT", label: "Erledigt", icon: CheckCircle2 },
{ key: "overdue", label: "Überfällig", icon: AlertCircle },
].map(({ key, label, icon: Icon }) => (
<Card key={key} className="shadow-none">
<CardContent className="p-5">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{label}</span>
<Icon
className={`size-4 ${key === "overdue" ? "text-orange-500" : ""}`}
/>
</div>
{stats.isLoading ? (
<Skeleton className="mt-4 h-9 w-14" />
) : (
<p
className={`mt-3 text-3xl font-semibold tabular-nums tracking-tight ${key === "overdue" && stats.data?.overdue ? "text-orange-600" : ""}`}
>
{n.text}
</Link>
</p>
))
) : (
<p>Keine Erinnerungen.</p>
)}
</section>
{key === "overdue"
? stats.data?.overdue
: stats.data?.statuses.find((s) => s.status === key)
?._count || 0}
</p>
)}
</CardContent>
</Card>
))}
</div>
<section className={panelClass}>
<h2 className="font-semibold">Durchlaufzeit</h2>
<p>
{stats.data?.averageDays?.toFixed(1) ?? "—"} Tage im Mittel · letzte{" "}
{stats.data?.sampleSize ?? 0} abgeschlossene Aufgaben.
</p>
<p className="text-sm text-muted-foreground">
Alle Kennzahlen beziehen sich auf Aufgaben in Ihrem
Berechtigungsbereich.
</p>
</section>
<div className="grid gap-4 md:grid-cols-2">
<section className={panelClass}>
<h2 className="font-semibold">Projekte</h2>
{stats.data?.projects.map((p) => (
<p key={p.project}>
{p.project || "Ohne Projekt"}: {p._count}
<div className="grid gap-5 xl:grid-cols-3">
<Card className="shadow-none xl:col-span-2">
<CardHeader className="flex-row items-center justify-between">
<div>
<CardTitle className="text-base">Neueste Aufgaben</CardTitle>
<CardDescription className="mt-1">
Zuletzt in Ihrem Bereich angelegt
</CardDescription>
</div>
<Layers3 className="size-4 text-muted-foreground" />
</CardHeader>
<CardContent className="px-0 pb-2">
{stats.data?.recent.length ? (
stats.data.recent.map((t) => (
<Link
key={t.id}
href={`/dashboard/tasks/${t.id}`}
className="flex items-center justify-between gap-4 border-t px-6 py-4 transition-colors hover:bg-muted/40"
>
<span className="text-sm font-medium">{t.was}</span>
<span className="flex items-center gap-3">
<StatusBadge status={t.status} />
<ArrowRight className="hidden size-4 text-muted-foreground sm:block" />
</span>
</Link>
))
) : (
<EmptyState
title={
stats.isLoading
? "Aufgaben werden geladen…"
: "Noch keine Aufgaben"
}
/>
)}
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Bell className="size-4" />
Erinnerungen
</CardTitle>
<CardDescription>Fristen und Hinweise für Sie</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{notifications.data?.filter((n) => !n.read).length ? (
notifications.data
.filter((n) => !n.read)
.map((n) => (
<div
key={n.id}
className="flex items-start gap-2 rounded-lg border p-3"
>
<Link
href={`/dashboard/tasks/${n.taskId}`}
className="flex-1 text-sm leading-relaxed hover:text-primary"
>
{n.text}
<span className="mt-1 block text-xs text-muted-foreground">{new Date(n.createdAt).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" })}</span>
</Link>
<Button
size="icon"
variant="ghost"
className="size-7 shrink-0"
aria-label="Als gelesen markieren"
onClick={async () => {
try {
await request("/api/workspace", {
action: "read",
id: n.id,
});
await qc.invalidateQueries();
} catch (e) {
toast.error(String(e));
}
}}
>
<Check className="size-4" />
</Button>
</div>
))
) : (
<EmptyState
title="Alles im Blick"
description="Aktuell gibt es keine ungelesenen Erinnerungen."
/>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-5 lg:grid-cols-3">
<Card className="shadow-none">
<CardHeader>
<CardTitle className="text-base">Projekte</CardTitle>
<CardDescription>Aufgaben nach Projekt</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{stats.data?.projects.map((p) => (
<div key={p.project}>
<div className="mb-2 flex justify-between text-sm">
<span>{p.project || "Ohne Projekt"}</span>
<span className="text-muted-foreground">{p._count}</span>
</div>
<div className="h-1.5 rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary/70"
style={{
width: `${total ? (p._count / total) * 100 : 0}%`,
}}
/>
</div>
</div>
))}
{!stats.isLoading && !stats.data?.projects.length && (
<p className="text-sm text-muted-foreground">
Noch keine Projektdaten.
</p>
)}
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader>
<CardTitle className="text-base">Prioritäten</CardTitle>
<CardDescription>Verteilung im Aufgabenbestand</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3, 4].map((p) => (
<div
key={p}
className="flex items-center justify-between text-sm"
>
<span className="flex items-center gap-2">
<span
className={`size-2 rounded-full ${p === 1 ? "bg-red-400" : p === 2 ? "bg-amber-400" : p === 3 ? "bg-blue-400" : "bg-slate-300"}`}
/>
{priorityNames[p]}
</span>
<span className="tabular-nums text-muted-foreground">
{stats.data?.priorities.find((x) => x.priority === p)
?._count || 0}
</span>
</div>
))}
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Timer className="size-4" />
Durchlaufzeit
</CardTitle>
<CardDescription>
Von der Erstellung bis zum Abschluss
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold tabular-nums">
{stats.data?.averageDays != null && stats.data.averageDays >= 0 ? stats.data.averageDays.toLocaleString("de-DE", { maximumFractionDigits: 1 }) : "—"}
<span className="ml-2 text-sm font-normal text-muted-foreground">
Tage
</span>
</p>
))}
</section>
<section className={panelClass}>
<h2 className="font-semibold">Prioritäten</h2>
{stats.data?.priorities.map((p) => (
<p key={p.priority}>
P{p.priority}: {p._count}
<p className="mt-4 text-xs leading-relaxed text-muted-foreground">
Durchschnitt der letzten {stats.data?.sampleSize ?? 0}{" "}
abgeschlossenen Aufgaben. Alle Kennzahlen berücksichtigen Ihren
Berechtigungsbereich.
</p>
))}
</section>
</CardContent>
</Card>
</div>
</div>
);

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +1,64 @@
"use client";
import { useState } from "react";
import { useState, type ReactNode } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
Pencil,
MoreHorizontal,
Archive,
Trash2,
Copy,
Upload,
FileText,
Download,
Send,
Link2,
CheckSquare,
MapPin,
CalendarDays,
Users,
Folder,
Repeat2,
Clock3,
UserPlus,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { TaskEditor } from "./task-editor";
import {
request,
useData,
Notice,
inputClass,
buttonClass,
panelClass,
statuses,
type Task,
type Me,
} from "./common";
import {
StatusBadge,
PriorityBadge,
Person,
AppSelect,
NameDialog,
TaskPicker,
LoadingRows,
EmptyState,
dateLabel,
useConfirm,
} from "./ui";
type Detail = Task & {
capabilities: string[];
comments: {
@ -28,367 +72,605 @@ type Detail = Task & {
children: Task[];
dependencies: { dependsOn: Task }[];
};
const actionLabels: Record<string, string> = {
"tasks.create": "Aufgabe erstellt",
"tasks.update": "Aufgabe aktualisiert",
"tasks.claim": "Aufgabe übernommen",
"comments.create": "Kommentar hinzugefügt",
"files.upload": "Datei hochgeladen",
"files.delete": "Datei entfernt",
"tasks.archive": "Aufgabe archiviert",
"tasks.restore": "Aufgabe wiederhergestellt",
};
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<Card className="shadow-none">
<CardHeader className="pb-4">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
export function TaskDetail({ id }: { id: string }) {
const query = useData<Detail>(`/api/tasks/${id}`);
const me = useData<Me>("/api/me");
const qc = useQueryClient();
const router = useRouter();
const [edit, setEdit] = useState(false);
const [template, setTemplate] = useState(false);
const [error, setError] = useState<unknown>();
const [busy, setBusy] = useState(false);
const [comment, setComment] = useState("");
const [dependency, setDependency] = useState("");
const { confirm, confirmation } = useConfirm();
const t = query.data;
const can = (p: string) => t?.capabilities.includes(p);
async function run(fn: () => Promise<unknown>) {
async function run(
fn: () => Promise<unknown>,
message = "Änderung gespeichert",
) {
setBusy(true);
setError(null);
try {
await fn();
await qc.invalidateQueries();
toast.success(message);
} catch (e) {
setError(e);
toast.error(e instanceof Error ? e.message : "Änderung fehlgeschlagen");
} finally {
setBusy(false);
}
}
if (query.isLoading) return <p>Lädt…</p>;
if (query.isLoading) return <LoadingRows />;
if (!t) return <Notice error={query.error} />;
const update = (data: unknown) =>
request(
`/api/tasks/${id}`,
{ version: t.version, ...(data as object) },
"PATCH",
);
return (
<div className="space-y-5">
<Link href="/dashboard/tasks" className="underline">
Zur Aufgabenliste
</Link>
<h1 className="text-3xl font-semibold">{t.was}</h1>
<p className="text-sm text-muted-foreground">ID: {t.id}</p>
<Notice error={error || query.error} />
<div className="flex flex-wrap gap-2">
{can("tasks.claim") && !t.assigneeId && !t.archivedAt && t.status !== "ERLEDIGT" && <button disabled={busy} className={buttonClass} onClick={() => run(() => request("/api/workspace", { action: "claim", taskId: id, version: t.version }))}>Aufgabe übernehmen</button>}
{can("tasks.update") && !t.archivedAt && (
<button className={buttonClass} onClick={() => setEdit(!edit)}>
Bearbeiten
</button>
)}
{can("tasks.change_status") &&
!t.archivedAt &&
Object.entries(statuses)
.filter(
([s]) =>
s !== t.status &&
(t.status !== "ERLEDIGT" || can("tasks.reopen")),
)
.map(([s, l]) => (
<button
<div className="space-y-6">
{confirmation}
<Button
asChild
variant="ghost"
size="sm"
className="-ml-3 text-muted-foreground"
>
<Link href="/dashboard/tasks">
<ArrowLeft className="mr-2 size-4" />
Alle Aufgaben
</Link>
</Button>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 max-w-3xl">
<div className="mb-3 flex items-center gap-2">
<StatusBadge status={t.status} />
<PriorityBadge value={t.priority} />
{t.archivedAt && <Badge variant="secondary">Archiviert</Badge>}
</div>
<h1 className="whitespace-pre-wrap break-words text-2xl font-semibold leading-snug tracking-tight">
{t.was}
</h1>
<p className="mt-2 flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPin className="size-4" />
{t.wo}
</p>
</div>
<div className="flex gap-2">
{can("tasks.claim") &&
!t.assigneeId &&
!t.archivedAt &&
t.status !== "ERLEDIGT" && (
<Button
disabled={busy}
className={buttonClass}
key={s}
onClick={() =>
run(() =>
request(
`/api/tasks/${id}`,
{ version: t.version, status: s },
"PATCH",
),
run(
() =>
request("/api/workspace", {
action: "claim",
taskId: id,
version: t.version,
}),
"Aufgabe übernommen",
)
}
>
{l}
</button>
))}
{can("tasks.archive") && (t.archivedAt || t.status === "ERLEDIGT") && (
<button
disabled={busy}
className={buttonClass}
onClick={() =>
run(() =>
request(
`/api/tasks/${id}`,
{ version: t.version, archived: !t.archivedAt },
"PATCH",
),
)
}
>
{t.archivedAt ? "Wiederherstellen" : "Archivieren"}
</button>
)}
{can("tasks.delete") && t.archivedAt && (
<button
disabled={busy}
className={buttonClass + " text-destructive"}
onClick={() => {
if (confirm("Aufgabe und Anhänge endgültig löschen?"))
run(async () => {
await request(
`/api/tasks/${id}`,
{ version: t.version },
"DELETE",
);
router.push("/dashboard/tasks");
});
}}
>
Endgültig löschen
</button>
)}
<button
disabled={busy}
className={buttonClass}
onClick={() => {
const name = prompt("Name der Vorlage", t.was.slice(0, 80));
if (name)
run(() =>
request("/api/workspace", {
action: "template",
taskId: id,
name,
}),
);
}}
>
Als Vorlage speichern
</button>
<UserPlus className="mr-2 size-4" />
Übernehmen
</Button>
)}
{can("tasks.update") && !t.archivedAt && (
<Button variant="outline" onClick={() => setEdit(true)}>
<Pencil className="mr-2 size-4" />
Bearbeiten
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
aria-label="Aufgabenaktionen"
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setTemplate(true)}>
<Copy className="mr-2 size-4" />
Als Vorlage speichern
</DropdownMenuItem>
{can("tasks.archive") &&
(t.archivedAt || t.status === "ERLEDIGT") && (
<DropdownMenuItem
disabled={busy}
onSelect={() =>
run(() => update({ archived: !t.archivedAt }))
}
>
<Archive className="mr-2 size-4" />
{t.archivedAt ? "Wiederherstellen" : "Archivieren"}
</DropdownMenuItem>
)}
{can("tasks.delete") && t.archivedAt && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onSelect={async () => {
if (
await confirm(
"Aufgabe endgültig löschen?",
"Die Aufgabe, ihre Kommentare und Anhänge werden gelöscht. Diese Aktion lässt sich nicht rückgängig machen.",
)
)
run(async () => {
await request(
`/api/tasks/${id}`,
{ version: t.version },
"DELETE",
);
router.push("/dashboard/tasks");
}, "Aufgabe gelöscht");
}}
>
<Trash2 className="mr-2 size-4" />
Endgültig löschen
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<Notice error={error} />
{edit && me.data && (
<TaskEditor
key={id}
me={me.data}
task={t}
onClose={() => setEdit(false)}
onSaved={() => setEdit(false)}
/>
)}
<section className={panelClass}>
<h2 className="font-semibold">Details</h2>
<dl className="grid gap-3 sm:grid-cols-2">
<div>
<dt>Ort</dt>
<dd>{t.wo}</dd>
</div>
<div>
<dt>Fälligkeit</dt>
<dd>{t.bisWann.slice(0, 10)}</dd>
</div>
<div>
<dt>Zuständig</dt>
<dd>{t.assignee?.name || "Teampool"}</dd>
</div>
<div>
<dt>Status</dt>
<dd>
{statuses[t.status as keyof typeof statuses]}
{t.archivedAt && " · Archiviert"}
</dd>
</div>
<div>
<dt>Projekt / Gruppe</dt>
<dd>
{t.project || "—"} / {t.owningGroup?.name}
</dd>
</div>
<div>
<dt>Priorität / Tags</dt>
<dd>
P{t.priority} · {t.tags.join(", ") || "—"}
</dd>
</div>
</dl>
{t.recurrenceDays && (
<p>Neue Aufgabe {t.recurrenceDays} Tage nach Abschluss.</p>
)}
</section>
<section className={panelClass}>
<h2 className="font-semibold">Checkliste</h2>
{t.checklist?.length ? (
t.checklist.map((c, i) => (
<label className="flex gap-2" key={i}>
<input
type="checkbox"
disabled={busy || !can("tasks.update") || !!t.archivedAt}
checked={c.done}
onChange={() =>
run(() =>
request(
`/api/tasks/${id}`,
{
version: t.version,
checklist: t.checklist?.map((item, j) =>
j === i ? { ...item, done: !item.done } : item,
),
},
"PATCH",
),
)
}
/>
{c.text}
</label>
))
) : (
<p>Keine Prüfpunkte.</p>
)}
</section>
<section className={panelClass}>
<h2 className="font-semibold">Unteraufgaben und Abhängigkeiten</h2>
{t.children.map((c) => (
<p key={c.id}>
<Link className="underline" href={`/dashboard/tasks/${c.id}`}>
{c.was}
</Link>{" "}
· {statuses[c.status as keyof typeof statuses]}
</p>
))}
{t.dependencies.map((d) => (
<div className="flex gap-3" key={d.dependsOn.id}>
<Link
className="underline"
href={`/dashboard/tasks/${d.dependsOn.id}`}
>
Benötigt: {d.dependsOn.was}
</Link>
{can("tasks.update") && (
<button
className={buttonClass}
onClick={() =>
run(() =>
request("/api/workspace", {
action: "dependency",
taskId: id,
dependsOnId: d.dependsOn.id,
remove: true,
}),
)
}
>
Entfernen
</button>
<NameDialog
key={String(template)}
open={template}
title="Vorlage speichern"
description="Die Angaben dieser Aufgabe können beim Erstellen neuer Aufgaben wiederverwendet werden."
initial={t.was.slice(0, 80)}
onClose={() => setTemplate(false)}
onSave={async (name) => {
await request("/api/workspace", {
action: "template",
taskId: id,
name,
});
await qc.invalidateQueries();
toast.success("Vorlage gespeichert");
}}
/>
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="min-w-0 space-y-5">
<Section title="Checkliste">
<div className="mb-4 flex items-center justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<CheckSquare className="size-4" />
{t.checklist?.filter((c) => c.done).length || 0} von{" "}
{t.checklist?.length || 0} erledigt
</span>
{can("tasks.update") && !t.archivedAt && (
<Button size="sm" variant="ghost" onClick={() => setEdit(true)}>
Bearbeiten
</Button>
)}
</div>
{t.checklist?.length ? (
<div className="space-y-3">
{t.checklist.map((c, i) => (
<label key={i} className="flex items-start gap-3 text-sm">
<Checkbox
checked={c.done}
disabled={busy || !can("tasks.update") || !!t.archivedAt}
onCheckedChange={() =>
run(() =>
update({
checklist: t.checklist?.map((x, j) =>
j === i ? { ...x, done: !x.done } : x,
),
}),
)
}
/>
<span
className={
c.done ? "text-muted-foreground line-through" : ""
}
>
{c.text}
</span>
</label>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
Noch keine Prüfpunkte hinterlegt.
</p>
)}
</div>
))}
{can("tasks.update") && !t.archivedAt && (
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
run(async () => {
await request("/api/workspace", {
action: "dependency",
taskId: id,
dependsOnId: dependency,
});
setDependency("");
});
}}
>
<input
required
aria-label="Abhängige Aufgaben-ID"
placeholder="Aufgaben-ID der Voraussetzung"
className={inputClass}
value={dependency}
onChange={(e) => setDependency(e.target.value)}
/>
<button disabled={busy} className={buttonClass}>
Verknüpfen
</button>
</form>
)}
</section>
<section className={panelClass}>
<h2 className="font-semibold">Dateien</h2>
{t.files.map((f) => (
<div className="flex gap-3 items-center" key={f.id}>
<a className="underline" href={f.path}>
{f.name}
</a>
<span>{Math.ceil(f.size / 1024)} KB</span>
{can("files.delete") && (
<button
disabled={busy}
className={buttonClass}
onClick={() => {
if (confirm("Datei löschen?"))
run(() =>
request(`/api/tasks/${id}/files/${f.id}`, {}, "DELETE"),
);
}}
>
Entfernen
</button>
)}
</div>
))}
{can("files.upload") && !t.archivedAt && (
<label className="block">
Datei hochladen (PDF, JPG, PNG, DOCX, XLSX · max. 10 MB)
<input
disabled={busy}
className={inputClass}
type="file"
accept=".pdf,.jpg,.jpeg,.png,.docx,.xlsx"
onChange={(e) => {
const f = e.target.files?.[0];
if (f)
run(async () => {
const form = new FormData();
form.append("file", f);
const res = await fetch(`/api/tasks/${id}/files`, {
method: "POST",
body: form,
});
if (!res.ok) throw new Error((await res.json()).error);
});
e.target.value = "";
}}
/>
</label>
)}
</section>
<section className={panelClass}>
<h2 className="font-semibold">Kommentare</h2>
{can("comments.create") && !t.archivedAt && (
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
run(async () => {
await request(`/api/tasks/${id}/comments`, {
content: comment,
});
setComment("");
});
}}
>
<textarea
required
maxLength={10000}
aria-label="Kommentar"
className={inputClass}
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
<button disabled={busy} className={buttonClass}>
Kommentieren
</button>
</form>
)}
{t.comments.map((c) => (
<article key={c.id} className="border-t pt-3">
<p className="text-sm text-muted-foreground">
{c.author.name} · {new Date(c.createdAt).toLocaleString("de-DE")}
</p>
<p className="whitespace-pre-wrap">{c.content}</p>
</article>
))}
</section>
<section className={panelClass}>
<h2 className="font-semibold">Letzte Aktivitäten</h2>
{t.history.map((h) => (
<p className="text-sm" key={h.id}>
{new Date(h.createdAt).toLocaleString("de-DE")} · {h.action}
</p>
))}
</section>
</Section>
<Tabs defaultValue="comments">
<TabsList className="max-w-full">
<TabsTrigger value="comments">
Kommentare{" "}
<span className="ml-1.5 text-xs opacity-60">
{t.comments.length}
</span>
</TabsTrigger>
<TabsTrigger value="files">
Dateien{" "}
<span className="ml-1.5 text-xs opacity-60">
{t.files.length}
</span>
</TabsTrigger>
<TabsTrigger value="links">Verknüpfungen</TabsTrigger>
</TabsList>
<TabsContent value="comments" className="space-y-4 pt-3">
{can("comments.create") && !t.archivedAt && (
<form
className="space-y-2 rounded-xl border bg-card p-4"
onSubmit={(e) => {
e.preventDefault();
run(async () => {
await request(`/api/tasks/${id}/comments`, {
content: comment,
});
setComment("");
}, "Kommentar hinzugefügt");
}}
>
<Textarea
required
maxLength={10000}
aria-label="Kommentar"
placeholder="Update oder Rückfrage hinzufügen…"
value={comment}
onChange={(e) => setComment(e.target.value)}
className="min-h-24 resize-y border-0 shadow-none focus-visible:ring-0"
/>
<div className="flex justify-end">
<Button disabled={busy || !comment.trim()} size="sm">
<Send className="mr-2 size-4" />
Kommentieren
</Button>
</div>
</form>
)}
{t.comments.length ? (
t.comments.map((c) => (
<article key={c.id} className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<Person name={c.author.name} />
<time className="text-xs text-muted-foreground">
{new Date(c.createdAt).toLocaleString("de-DE", {
dateStyle: "medium",
timeStyle: "short",
})}
</time>
</div>
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-relaxed">
{c.content}
</p>
</article>
))
) : (
<EmptyState
title="Noch keine Kommentare"
description="Halten Sie Absprachen und Fortschritte direkt an der Aufgabe fest."
/>
)}
</TabsContent>
<TabsContent value="files" className="space-y-3 pt-3">
{can("files.upload") && !t.archivedAt && (
<label className="flex cursor-pointer flex-col items-center rounded-xl border border-dashed bg-muted/20 p-7 text-center hover:bg-muted/50">
<Upload className="mb-3 size-6 text-primary" />
<span className="text-sm font-medium">
Datei auswählen und hochladen
</span>
<span className="mt-1 text-xs text-muted-foreground">
PDF, JPG, PNG, Word oder Excel · bis 10 MB
</span>
<input
className="sr-only"
aria-label="Datei hochladen"
type="file"
disabled={busy}
accept=".pdf,.jpg,.jpeg,.png,.docx,.xlsx"
onChange={(e) => {
const f = e.target.files?.[0];
if (f)
run(async () => {
const form = new FormData();
form.append("file", f);
const res = await fetch(`/api/tasks/${id}/files`, {
method: "POST",
body: form,
});
if (!res.ok)
throw new Error((await res.json()).error);
}, "Datei hochgeladen");
e.target.value = "";
}}
/>
</label>
)}
{t.files.map((f) => (
<div
key={f.id}
className="flex items-center gap-3 rounded-xl border bg-card p-4"
>
<FileText className="size-6 shrink-0 text-muted-foreground" />
<a
href={f.path}
className="min-w-0 flex-1 text-sm font-medium hover:text-primary"
>
<span className="block truncate">{f.name}</span>
<span className="text-xs font-normal text-muted-foreground">
{Math.ceil(f.size / 1024)} KB
</span>
</a>
<Button variant="ghost" size="icon" asChild>
<a href={f.path} aria-label={`${f.name} herunterladen`}>
<Download className="size-4" />
</a>
</Button>
{can("files.delete") && (
<Button
variant="ghost"
size="icon"
disabled={busy}
aria-label={`${f.name} löschen`}
onClick={async () => {
if (
await confirm(
"Datei löschen?",
`„${f.name}“ wird endgültig entfernt.`,
)
)
run(
() =>
request(
`/api/tasks/${id}/files/${f.id}`,
{},
"DELETE",
),
"Datei gelöscht",
);
}}
>
<Trash2 className="size-4 text-muted-foreground" />
</Button>
)}
</div>
))}
{!t.files.length && (
<p className="p-4 text-center text-sm text-muted-foreground">
Noch keine Dateien angehängt.
</p>
)}
</TabsContent>
<TabsContent value="links" className="space-y-5 pt-3">
<Section title="Unteraufgaben">
{t.children.length ? (
t.children.map((c) => (
<Link
key={c.id}
href={`/dashboard/tasks/${c.id}`}
className="flex items-center justify-between gap-3 border-b py-3 text-sm last:border-0 hover:text-primary"
>
<span>{c.was}</span>
<StatusBadge status={c.status} />
</Link>
))
) : (
<p className="text-sm text-muted-foreground">
Keine Unteraufgaben verknüpft. Beim Anlegen einer Aufgabe
kann diese als übergeordnete Aufgabe ausgewählt werden.
</p>
)}
</Section>
<Section title="Voraussetzungen">
{t.dependencies.map((d) => (
<div
key={d.dependsOn.id}
className="mb-3 flex items-center gap-2 rounded-lg border p-3"
>
<Link2 className="size-4 shrink-0 text-muted-foreground" />
<Link
className="flex-1 text-sm hover:text-primary"
href={`/dashboard/tasks/${d.dependsOn.id}`}
>
{d.dependsOn.was}
</Link>
{can("tasks.update") && !t.archivedAt && (
<Button
variant="ghost"
size="icon"
aria-label="Verknüpfung entfernen"
onClick={async () => {
if (
await confirm(
"Verknüpfung entfernen?",
"Die verknüpfte Aufgabe selbst bleibt erhalten.",
"Ja, entfernen",
)
)
run(() =>
request("/api/workspace", {
action: "dependency",
taskId: id,
dependsOnId: d.dependsOn.id,
remove: true,
}),
);
}}
>
<Trash2 className="size-4" />
</Button>
)}
</div>
))}
{can("tasks.update") && !t.archivedAt && (
<form
className="flex flex-wrap gap-2"
onSubmit={(e) => {
e.preventDefault();
run(async () => {
await request("/api/workspace", {
action: "dependency",
taskId: id,
dependsOnId: dependency,
});
setDependency("");
}, "Aufgabe verknüpft");
}}
>
<div className="min-w-48 flex-1">
<TaskPicker
label="Voraussetzung auswählen"
value={dependency}
onChange={setDependency}
group={t.owningGroupId}
exclude={id}
/>
</div>
<Button variant="outline" disabled={busy || !dependency}>
<Link2 className="mr-2 size-4" />
Verknüpfen
</Button>
</form>
)}
</Section>
</TabsContent>
</Tabs>
</div>
<aside className="space-y-5">
<Section title="Aufgabendetails">
<div className="space-y-5">
<div>
<p className="mb-2 text-xs text-muted-foreground">Status</p>
<AppSelect
label="Aufgabenstatus"
value={t.status}
disabled={
busy || !can("tasks.change_status") || !!t.archivedAt
}
options={Object.entries(statuses)
.filter(
([s]) =>
t.status !== "ERLEDIGT" ||
s === "ERLEDIGT" ||
can("tasks.reopen"),
)
.map(([value, label]) => ({ value, label }))}
onValueChange={(status) =>
run(() => update({ status }), "Status aktualisiert")
}
/>
</div>
<div>
<p className="mb-2 text-xs text-muted-foreground">Zuständig</p>
<Person name={t.assignee?.name || "Teampool"} />
</div>
<div className="space-y-3 text-sm">
<p className="flex items-center gap-2">
<CalendarDays className="size-4 text-muted-foreground" />
{dateLabel(t.bisWann)}
</p>
<p className="flex items-center gap-2">
<Users className="size-4 text-muted-foreground" />
{t.owningGroup?.name}
</p>
{t.project && (
<p className="flex items-center gap-2">
<Folder className="size-4 text-muted-foreground" />
{t.project}
</p>
)}
{t.recurrenceDays && (
<p className="flex items-center gap-2">
<Repeat2 className="size-4 text-muted-foreground" />
Nach Abschluss: {t.recurrenceDays} Tage
</p>
)}
</div>
{t.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{t.tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="font-normal"
>
{tag}
</Badge>
))}
</div>
)}
</div>
</Section>
<Section title="Letzte Aktivitäten">
<div className="space-y-4">
{t.history.map((h) => (
<div key={h.id} className="flex gap-3">
<Clock3 className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<div>
<p className="text-xs font-medium">
{actionLabels[h.action] ||
h.action
.replace(/^tasks\./, "Aufgabe: ")
.replace(/^access\./, "Rechte: ")}
</p>
<time className="mt-1 block text-xs text-muted-foreground">
{new Date(h.createdAt).toLocaleString("de-DE", {
dateStyle: "medium",
timeStyle: "short",
})}
</time>
</div>
</div>
))}
{!t.history.length && (
<p className="text-xs text-muted-foreground">
Noch keine Aktivitäten vorhanden.
</p>
)}
</div>
</Section>
</aside>
</div>
</div>
);
}

View File

@ -1,6 +1,19 @@
"use client";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { TaskPicker, AppSelect } from "./ui";
import { Loader2, Save, ChevronDown } from "lucide-react";
import { toast } from "sonner";
import { allowed } from "@/lib/rbac";
import {
request,
@ -8,8 +21,6 @@ import {
Field,
Notice,
inputClass,
buttonClass,
panelClass,
today,
type Task,
type Me,
@ -40,6 +51,7 @@ export function TaskEditor({
});
const [error, setError] = useState<unknown>();
const [busy, setBusy] = useState(false);
const [templateId, setTemplateId] = useState("");
const qc = useQueryClient();
const assign = allowed(me, "tasks.assign", {
owningGroupId: form.owningGroupId,
@ -90,6 +102,7 @@ export function TaskEditor({
task ? "PATCH" : "POST",
);
await qc.invalidateQueries();
toast.success(task ? "Aufgabe aktualisiert" : "Aufgabe angelegt");
onSaved();
} catch (e) {
setError(e);
@ -98,171 +111,186 @@ export function TaskEditor({
}
}
return (
<section
className={panelClass}
aria-label={task ? "Aufgabe bearbeiten" : "Neue Aufgabe"}
<Dialog
open
onOpenChange={(open) => {
if (!open && !busy) onClose();
}}
>
<h2 className="text-xl font-semibold">
{task ? "Aufgabe bearbeiten" : "Neue Aufgabe"}
</h2>
<Notice error={error} />
{!task && (
<Field label="Vorlage">
<select
className={inputClass}
defaultValue=""
onChange={(e) => {
const t = templates.data?.find((t) => t.id === e.target.value);
if (t)
setForm(
(f) =>
({
...f,
...t.data,
tags: Array.isArray(t.data.tags)
? t.data.tags.join(", ")
: "",
checklist: Array.isArray(t.data.checklist)
? t.data.checklist.map((c) => c.text).join("\n")
: "",
}) as typeof form,
);
}}
>
<option value="">Ohne Vorlage</option>
{templates.data?.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</Field>
)}
<form onSubmit={save} className="grid gap-4 md:grid-cols-2">
<Field label="Beschreibung">
<textarea
required
maxLength={10000}
className={inputClass}
value={form.was}
onChange={(e) => change("was", e.target.value)}
/>
</Field>
<Field label="Ort / Standort">
<input
required
maxLength={200}
className={inputClass}
value={form.wo}
onChange={(e) => change("wo", e.target.value)}
/>
</Field>
<Field label="Fällig am">
<input
required
type="date"
className={inputClass}
value={form.bisWann}
onChange={(e) => change("bisWann", e.target.value)}
/>
</Field>
<Field label="Gruppe">
<select
required
className={inputClass}
value={form.owningGroupId}
onChange={(e) =>
setForm((f) => ({
...f,
owningGroupId: e.target.value,
assigneeId: "",
}))
}
>
{me.groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</Field>
<Field label="Zuständig">
<select
disabled={!assign}
className={inputClass}
value={form.assigneeId}
onChange={(e) => change("assigneeId", e.target.value)}
>
<option value="">Teampool (nicht zugewiesen)</option>
{Array.isArray(users.data) &&
users.data.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</Field>
<Field label="Priorität">
<select
className={inputClass}
value={form.priority}
onChange={(e) => change("priority", Number(e.target.value))}
>
{["Dringend", "Hoch", "Normal", "Niedrig"].map((s, i) => (
<option key={s} value={i + 1}>
{s}
</option>
))}
</select>
</Field>
<Field label="Projekt">
<input
maxLength={120}
className={inputClass}
value={form.project}
onChange={(e) => change("project", e.target.value)}
/>
</Field>
<Field label="Tags (Komma getrennt)">
<input
className={inputClass}
value={form.tags}
onChange={(e) => change("tags", e.target.value)}
/>
</Field>
<Field label="Wiederholung nach Abschluss (Tage, 0 = aus)">
<input
type="number"
min={0}
max={365}
className={inputClass}
value={form.recurrenceDays}
onChange={(e) => change("recurrenceDays", Number(e.target.value))}
/>
</Field>
<Field label="Übergeordnete Aufgaben-ID (optional)">
<input
className={inputClass}
value={form.parentId}
onChange={(e) => change("parentId", e.target.value)}
/>
</Field>
<Field label="Checkliste (ein Punkt pro Zeile)">
<textarea
className={inputClass}
value={form.checklist}
onChange={(e) => change("checklist", e.target.value)}
/>
</Field>
<div className="flex items-end gap-2">
<button className={buttonClass} disabled={busy}>
Speichern
</button>
<button type="button" className={buttonClass} onClick={onClose}>
Abbrechen
</button>
</div>
</form>
</section>
<DialogContent className="flex max-h-[90dvh] flex-col overflow-hidden rounded-xl sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{task ? "Aufgabe bearbeiten" : "Neue Aufgabe"}
</DialogTitle>
<DialogDescription>
Was ist zu tun, wo und bis wann?
</DialogDescription>
</DialogHeader>
<Notice error={error} />
<form onSubmit={save} className="flex min-h-0 flex-col gap-4">
<div className="grid gap-4 overflow-y-auto pr-1 md:grid-cols-2">
{!task && (templates.data?.length ?? 0) > 0 && (
<div className="md:col-span-2">
<Field label="Vorlage">
<AppSelect label="Vorlage" value={templateId}
options={[{ value: "", label: "Ohne Vorlage" }, ...(templates.data ?? []).map(t => ({ value: t.id, label: t.name }))]}
onValueChange={(id) => {
setTemplateId(id);
const t = templates.data?.find((t) => t.id === id);
if (t)
setForm(
(f) =>
({
...f,
...t.data,
tags: Array.isArray(t.data.tags)
? t.data.tags.join(", ")
: "",
checklist: Array.isArray(t.data.checklist)
? t.data.checklist.map((c) => c.text).join("\n")
: "",
}) as typeof form,
);
}} />
</Field>
</div>
)}
<Field label="Beschreibung">
<Textarea
required
maxLength={10000}
className={inputClass}
value={form.was}
onChange={(e) => change("was", e.target.value)}
/>
</Field>
<Field label="Ort / Standort">
<Input
required
maxLength={200}
className={inputClass}
value={form.wo}
onChange={(e) => change("wo", e.target.value)}
/>
</Field>
<Field label="Fällig am">
<Input
required
type="date"
className={inputClass}
value={form.bisWann}
onChange={(e) => change("bisWann", e.target.value)}
/>
</Field>
<Field label="Gruppe">
<AppSelect
label="Gruppe"
value={form.owningGroupId}
onValueChange={(id) =>
setForm((f) => ({ ...f, owningGroupId: id, assigneeId: "" }))
}
options={me.groups.map((g) => ({ value: g.id, label: g.name }))}
/>
</Field>
<Field label="Zuständig">
<AppSelect
label="Zuständig"
disabled={!assign}
value={form.assigneeId}
onValueChange={(id) => change("assigneeId", id)}
options={[
{ value: "", label: "Teampool · noch nicht zugewiesen" },
...(Array.isArray(users.data)
? users.data.map((u) => ({ value: u.id, label: u.name }))
: []),
]}
/>
</Field>
<Field label="Priorität">
<AppSelect
label="Priorität"
value={String(form.priority)}
onValueChange={(v) => change("priority", Number(v))}
options={["Dringend", "Hoch", "Normal", "Niedrig"].map(
(label, i) => ({ value: String(i + 1), label }),
)}
/>
</Field>
<details className="md:col-span-2 rounded-lg border p-4">
<summary className="flex cursor-pointer list-none items-center gap-2 text-sm font-medium">
<ChevronDown className="size-4" />
Weitere Angaben{" "}
<span className="font-normal text-muted-foreground">
· Projekt, Checkliste, Wiederholung
</span>
</summary>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<Field label="Projekt">
<Input
maxLength={120}
className={inputClass}
value={form.project}
onChange={(e) => change("project", e.target.value)}
/>
</Field>
<Field label="Tags (Komma getrennt)">
<Input
className={inputClass}
value={form.tags}
onChange={(e) => change("tags", e.target.value)}
/>
</Field>
<Field label="Wiederholung nach Abschluss (Tage, 0 = aus)">
<Input
type="number"
min={0}
max={365}
className={inputClass}
value={form.recurrenceDays}
onChange={(e) =>
change("recurrenceDays", Number(e.target.value))
}
/>
</Field>
<Field label="Übergeordnete Aufgabe">
<TaskPicker
label="Aufgabe auswählen"
value={form.parentId}
onChange={(id) => change("parentId", id)}
exclude={task?.id}
group={form.owningGroupId}
/>
</Field>
<Field label="Checkliste (ein Punkt pro Zeile)">
<Textarea
className={inputClass}
value={form.checklist}
onChange={(e) => change("checklist", e.target.value)}
/>
</Field>
</div>
</details>
</div>
<div className="flex shrink-0 justify-end gap-2 border-t pt-4">
<Button
type="button"
variant="outline"
disabled={busy}
onClick={onClose}
>
Abbrechen
</Button>
<Button disabled={busy}>
{busy ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Save className="mr-2 size-4" />
)}
Speichern
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -2,20 +2,73 @@
import { useState } from "react";
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
import {
Plus,
Search,
SlidersHorizontal,
Download,
List,
Columns3,
Archive,
Bookmark,
MoreHorizontal,
ChevronLeft,
ChevronRight,
MapPin,
CalendarDays,
X,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { capable } from "@/lib/rbac";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { TaskEditor } from "./task-editor";
import {
request,
useData,
Notice,
inputClass,
buttonClass,
panelClass,
Field,
statuses,
today,
type Task,
type Me,
} from "./common";
import {
PageHeader,
EmptyState,
LoadingRows,
StatusBadge,
PriorityBadge,
Person,
AppSelect,
NameDialog,
dateLabel,
useConfirm,
} from "./ui";
export function Tasks({
completed = false,
calendar = false,
@ -35,10 +88,11 @@ export function Tasks({
const [creating, setCreating] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const [month, setMonth] = useState(today().slice(0, 7));
const [message, setMessage] = useState<unknown>();
const [busy, setBusy] = useState(false);
const [project, setProject] = useState("");
const [tag, setTag] = useState("");
const [busy, setBusy] = useState(false);
const [saveFilter, setSaveFilter] = useState(false);
const { confirm, confirmation } = useConfirm();
const query = new URLSearchParams({
q,
status,
@ -63,11 +117,18 @@ export function Tasks({
const filters = useData<
{ id: string; name: string; query: Record<string, string> }[]
>("/api/workspace?mode=filters");
const reset = () => {
setPage(1);
setSelected([]);
};
const toggle = (id: string) =>
setSelected((s) =>
s.includes(id) ? s.filter((x) => x !== id) : [...s, id],
);
async function batch(action: { status?: string; archived?: boolean }) {
setBusy(true);
setMessage(null);
try {
const result = await request<{
const r = await request<{
results: { id: string; ok: boolean; error?: string }[];
}>("/api/workspace", {
action: "batch",
@ -76,71 +137,116 @@ export function Tasks({
.map((t) => ({ id: t.id, version: t.version })),
...action,
});
const failures = result.results.filter((r) => !r.ok);
setMessage(
failures.length
? failures.map((f) => `${f.id}: ${f.error}`).join("; ")
: "Änderungen gespeichert",
);
const failures = r.results.filter((x) => !x.ok);
if (failures.length)
toast.error(`${failures.length} Änderungen nicht möglich`, {
description: failures.map((f) => f.error).join(" · "),
});
else toast.success("Aufgaben aktualisiert");
setSelected([]);
await qc.invalidateQueries();
} catch (e) {
setMessage(e);
toast.error(e instanceof Error ? e.message : "Änderung fehlgeschlagen");
} finally {
setBusy(false);
}
}
const items = tasks.data?.items || [];
const due = (t: Task) => (
<span
className={`inline-flex items-center gap-1.5 whitespace-nowrap text-xs ${t.status !== "ERLEDIGT" && t.bisWann.slice(0, 10) < today() ? "text-red-600 dark:text-red-400" : "text-muted-foreground"}`}
>
<CalendarDays className="size-3.5" />
{dateLabel(t.bisWann)}
</span>
);
const card = (t: Task) => (
<article key={t.id} className={panelClass}>
<div className="flex gap-3">
<input
<article
key={t.id}
className="rounded-xl border bg-card p-4 shadow-sm transition-shadow hover:shadow-md"
>
<div className="mb-3 flex items-center justify-between">
<StatusBadge status={t.status} />
<Checkbox
aria-label={`${t.was} auswählen`}
type="checkbox"
checked={selected.includes(t.id)}
onChange={(e) =>
setSelected((s) =>
e.target.checked ? [...s, t.id] : s.filter((id) => id !== t.id),
)
}
onCheckedChange={() => toggle(t.id)}
/>
<Link
className="font-semibold underline"
href={`/dashboard/tasks/${t.id}`}
>
{t.was}
</Link>
</div>
<p>
{t.wo} · {t.project || "Kein Projekt"}
</p>
<p
className={
t.bisWann.slice(0, 10) < today() && t.status !== "ERLEDIGT"
? "text-destructive"
: "text-muted-foreground"
}
<Link
href={`/dashboard/tasks/${t.id}`}
className="block font-medium leading-snug hover:text-primary"
>
{t.bisWann.slice(0, 10)} · {statuses[t.status as keyof typeof statuses]}{" "}
· P{t.priority}
{t.was}
</Link>
<p className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="size-3.5" />
{t.wo}
</p>
<p className="text-sm">
{t.assignee?.name || "Teampool"} · {t.owningGroup?.name}
</p>
<p className="text-sm text-muted-foreground">{t.tags.join(" · ")}</p>
<div className="my-4 flex items-center justify-between gap-2">
{due(t)}
<PriorityBadge value={t.priority} />
</div>
<div className="flex items-center justify-between border-t pt-3">
<Person name={t.assignee?.name || "Teampool"} />
<span className="max-w-28 truncate text-xs text-muted-foreground">
{t.owningGroup?.name}
</span>
</div>
</article>
);
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-3xl font-semibold">
{calendar ? "Kalender" : "Aufgaben"}
</h1>
<div className="space-y-6">
{confirmation}
<PageHeader
title={calendar ? "Kalender" : "Aufgaben"}
description={
calendar
? "Anstehende Aufgaben nach Fälligkeit. Alle Termine in einer Ansicht."
: "Planen, zuweisen und gemeinsam erledigen."
}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
aria-label="Weitere Aufgabenaktionen"
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setSaveFilter(true)}>
<Bookmark className="mr-2 size-4" />
Ansicht speichern
</DropdownMenuItem>
{capable(me.data, "tasks.export") && (
<>
<DropdownMenuSeparator />
{[
["csv", "CSV-Datei"],
["xlsx", "Excel-Datei"],
["ics", "Kalenderdatei"],
].map(([format, label]) => (
<DropdownMenuItem key={format} asChild>
<a href={`/api/transfer?format=${format}`}>
<Download className="mr-2 size-4" />
{label}
</a>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{capable(me.data, "tasks.create") && (
<button className={buttonClass} onClick={() => setCreating(true)}>
<Button onClick={() => setCreating(true)}>
<Plus className="mr-2 size-4" />
Neue Aufgabe
</button>
</Button>
)}
</div>
</PageHeader>
{creating && me.data && (
<TaskEditor
me={me.data}
@ -148,231 +254,404 @@ export function Tasks({
onSaved={() => setCreating(false)}
/>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<input
aria-label="Aufgaben suchen"
placeholder="Beschreibung, Ort oder Projekt suchen"
className={inputClass}
value={q}
onChange={(e) => {
setQ(e.target.value);
setPage(1);
setSelected([]);
}}
/>
<select
aria-label="Status"
className={inputClass}
<NameDialog
key={String(saveFilter)}
open={saveFilter}
title="Ansicht speichern"
description="Speichert Ihre aktuellen Such- und Filtereinstellungen."
onClose={() => setSaveFilter(false)}
onSave={async (name) => {
await request("/api/workspace", {
action: "filter",
name,
query: Object.fromEntries(query),
});
await filters.refetch();
toast.success("Ansicht gespeichert");
}}
/>
<div className="flex flex-wrap items-center justify-between gap-3 border-b pb-4">
<Tabs
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(1);
setSelected([]);
onValueChange={(v) => {
setStatus(v);
reset();
}}
>
<option value="OPEN">Offene Aufgaben</option>
<option value="ALL">Alle Status</option>
{Object.entries(statuses).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
<input
aria-label="Projekt filtern"
placeholder="Projekt (genau)"
className={inputClass}
value={project}
onChange={(e) => {
setProject(e.target.value);
setPage(1);
}}
/>
<input
aria-label="Tag filtern"
placeholder="Tag"
className={inputClass}
value={tag}
onChange={(e) => {
setTag(e.target.value);
setPage(1);
}}
/>
</div>
<div className="flex flex-wrap gap-3 items-center">
<label>
<input
type="checkbox"
checked={archived}
onChange={(e) => {
setArchived(e.target.checked);
setPage(1);
setSelected([]);
}}
/>{" "}
Archiv
</label>
{!calendar && (
<button className={buttonClass} onClick={() => setKanban(!kanban)}>
{kanban ? "Listenansicht" : "Kanbanansicht"}
</button>
)}
{calendar && (
<input
<TabsList className="h-9">
<TabsTrigger value="OPEN">Offen</TabsTrigger>
<TabsTrigger value="IN_BEARBEITUNG">In Arbeit</TabsTrigger>
<TabsTrigger value="ERLEDIGT">Erledigt</TabsTrigger>
<TabsTrigger value="ALL">Alle</TabsTrigger>
</TabsList>
</Tabs>
{calendar ? (
<Input
className="w-44"
aria-label="Monat"
type="month"
value={month}
className={inputClass + " max-w-48"}
onChange={(e) => {
if (e.target.value) {
setMonth(e.target.value);
setPage(1);
reset();
}
}}
/>
)}
<button
className={buttonClass}
onClick={async () => {
const name = prompt("Name des Filters");
if (name)
try {
await request("/api/workspace", {
action: "filter",
name,
query: Object.fromEntries(query),
});
await filters.refetch();
} catch (e) {
setMessage(e);
}
}}
>
Filter speichern
</button>
<select
aria-label="Gespeicherte Filter"
className={inputClass + " max-w-56"}
defaultValue=""
onChange={(e) => {
const f = filters.data?.find((f) => f.id === e.target.value);
if (f) {
setQ(f.query.q || "");
setStatus(f.query.status || "OPEN");
setArchived(f.query.archived === "true");
setProject(f.query.project || "");
setTag(f.query.tag || "");
setPage(1);
}
}}
>
<option value="">Gespeicherte Filter</option>
{filters.data?.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
{capable(me.data, "tasks.export") && (
<>
<a className={buttonClass} href="/api/transfer">
CSV exportieren
</a>
<a className={buttonClass} href="/api/transfer?format=xlsx">
Excel exportieren
</a>
<a className={buttonClass} href="/api/transfer?format=ics">
Kalender exportieren
</a>
</>
) : (
<div className="flex rounded-lg border p-1">
<Button
aria-label="Listenansicht"
title="Liste"
size="sm"
variant={!kanban ? "secondary" : "ghost"}
onClick={() => setKanban(false)}
>
<List className="size-4" />
</Button>
<Button
aria-label="Kanbanansicht"
title="Board"
size="sm"
variant={kanban ? "secondary" : "ghost"}
onClick={() => setKanban(true)}
>
<Columns3 className="size-4" />
</Button>
</div>
)}
</div>
<Notice error={tasks.error || me.error} />
{!!message && (
<p role="status">
{message instanceof Error ? message.message : String(message)}
</p>
)}
{selected.length > 0 && (
<div className="flex flex-wrap gap-2 items-center">
<span>{selected.length} ausgewählt</span>
{Object.entries(statuses).map(([s, l]) => (
<button
disabled={busy}
className={buttonClass}
key={s}
onClick={() => batch({ status: s })}
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-48 flex-1 sm:max-w-sm">
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
<Input
aria-label="Aufgaben suchen"
placeholder="Aufgaben suchen…"
value={q}
onChange={(e) => {
setQ(e.target.value);
reset();
}}
className="pl-9"
/>
</div>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">
<SlidersHorizontal className="mr-2 size-4" />
Filter
{(project || tag || archived) && (
<span className="ml-2 size-2 rounded-full bg-primary" />
)}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="space-y-4">
<p className="text-sm font-semibold">Aufgaben eingrenzen</p>
<Field label="Projekt">
<Input
placeholder="Projektname"
value={project}
onChange={(e) => {
setProject(e.target.value);
reset();
}}
/>
</Field>
<Field label="Tag">
<Input
placeholder="Zum Beispiel Wartung"
value={tag}
onChange={(e) => {
setTag(e.target.value);
reset();
}}
/>
</Field>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={archived}
onCheckedChange={(v) => {
setArchived(!!v);
reset();
}}
/>
Archivierte Aufgaben
</label>
<Button
variant="ghost"
size="sm"
onClick={() => {
setProject("");
setTag("");
setArchived(false);
reset();
}}
>
{l}
</button>
))}
<button
Filter zurücksetzen
</Button>
</PopoverContent>
</Popover>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost">
<Bookmark className="mr-2 size-4" />
Ansichten
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{filters.data?.map((f) => (
<div key={f.id} className="flex items-center">
<DropdownMenuItem
className="flex-1"
onSelect={() => {
setQ(f.query.q || "");
setStatus(f.query.status || "OPEN");
setArchived(f.query.archived === "true");
setProject(f.query.project || "");
setTag(f.query.tag || "");
reset();
}}
>
{f.name}
</DropdownMenuItem>
<Button
variant="ghost"
size="icon"
aria-label={`${f.name} löschen`}
onClick={async () => {
if (
await confirm(
"Ansicht löschen?",
`„${f.name}“ wird aus Ihren gespeicherten Ansichten entfernt.`,
)
) {
try {
await request("/api/workspace", {
action: "deleteFilter",
id: f.id,
});
await filters.refetch();
} catch (e) {
toast.error(String(e));
}
}
}}
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setSaveFilter(true)}>
<Plus className="mr-2 size-4" />
Aktuelle Ansicht speichern
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span className="ml-auto text-xs text-muted-foreground">
{tasks.data?.total ?? "…"} Aufgaben
</span>
</div>
{(archived || project || tag) && (
<div className="flex flex-wrap gap-2">
{archived && (
<Badge variant="secondary">
<Archive className="mr-1 size-3" />
Archiv
</Badge>
)}
{project && <Badge variant="secondary">{project}</Badge>}
{tag && <Badge variant="secondary">#{tag}</Badge>}
</div>
)}
<Notice error={tasks.error || me.error} />
{!!selected.length && (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-primary/20 bg-primary/5 px-4 py-3">
<span className="mr-2 text-sm font-medium">
{selected.length} ausgewählt
</span>
<AppSelect
label="Status ändern"
options={Object.entries(statuses).map(([value, label]) => ({
value,
label,
}))}
onValueChange={(s) => batch({ status: s })}
disabled={busy}
className={buttonClass}
onClick={() => batch({ archived: !archived })}
className="w-40"
/>
{capable(me.data, "tasks.archive") && (
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => batch({ archived: !archived })}
>
<Archive className="mr-2 size-4" />
{archived ? "Wiederherstellen" : "Archivieren"}
</Button>
)}
<Button
aria-label="Auswahl aufheben"
variant="ghost"
size="icon"
className="ml-auto"
onClick={() => setSelected([])}
>
{archived ? "Wiederherstellen" : "Archivieren"}
</button>
<X className="size-4" />
</Button>
</div>
)}
{tasks.isLoading ? (
<p>Lädt…</p>
) : !tasks.data?.items.length ? (
<p>Keine Aufgaben gefunden.</p>
) : kanban ? (
<div className="grid gap-4 md:grid-cols-3">
{Object.entries(statuses).map(([s, l]) => (
<section key={s} className="space-y-3">
<h2 className="font-semibold">{l}</h2>
{tasks.data.items.filter((t) => t.status === s).map(card)}
</section>
))}
<LoadingRows />
) : !items.length ? (
<div className="rounded-xl border bg-card">
<EmptyState
title="Keine Aufgaben gefunden"
description="Ändern Sie Ihre Suche oder legen Sie eine neue Aufgabe an."
/>
</div>
) : kanban ? (
<>
<p className="text-xs text-muted-foreground">
Board zeigt {items.length} von {tasks.data?.total} Aufgaben der
aktuellen Auswahl.
</p>
<div className="grid items-start gap-5 md:grid-cols-3">
{Object.entries(statuses).map(([s, l]) => (
<section key={s} className="space-y-3 rounded-xl bg-muted/50 p-3">
<div className="flex items-center gap-2 px-1 py-2">
<StatusBadge status={s} />
<span className="text-xs text-muted-foreground">
{items.filter((t) => t.status === s).length}
</span>
</div>
{items.filter((t) => t.status === s).map(card)}
{!items.some((t) => t.status === s) && (
<p className="px-2 py-8 text-center text-xs text-muted-foreground">
Keine Aufgaben: {l}
</p>
)}
</section>
))}
</div>
</>
) : calendar ? (
<div className="space-y-4">
{[
...new Set(tasks.data.items.map((t) => t.bisWann.slice(0, 10))),
].map((day) => (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{[...new Set(items.map((t) => t.bisWann.slice(0, 10)))].map((day) => (
<section key={day}>
<h2 className="font-semibold mb-2">{day}</h2>
<div className="grid gap-3 md:grid-cols-2">
{tasks.data.items
.filter((t) => t.bisWann.startsWith(day))
.map(card)}
<h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
<CalendarDays className="size-4 text-primary" />
{dateLabel(day)}
</h2>
<div className="grid gap-3">
{items.filter((t) => t.bisWann.startsWith(day)).map(card)}
</div>
</section>
))}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{tasks.data.items.map(card)}
</div>
<>
<div className="hidden overflow-hidden rounded-xl border bg-card md:block">
<Table>
<TableHeader>
<TableRow className="bg-muted/40">
<TableHead className="w-10">
<Checkbox
aria-label="Alle Aufgaben dieser Seite auswählen"
checked={
items.every((t) => selected.includes(t.id))
? true
: selected.length
? "indeterminate"
: false
}
onCheckedChange={(v) =>
setSelected(v ? items.map((t) => t.id) : [])
}
/>
</TableHead>
<TableHead>Aufgabe</TableHead>
<TableHead>Status</TableHead>
<TableHead>Priorität</TableHead>
<TableHead>Fällig am</TableHead>
<TableHead>Zuständig</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((t) => (
<TableRow key={t.id}>
<TableCell>
<Checkbox
aria-label={`${t.was} auswählen`}
checked={selected.includes(t.id)}
onCheckedChange={() => toggle(t.id)}
/>
</TableCell>
<TableCell className="max-w-sm py-4">
<Link
href={`/dashboard/tasks/${t.id}`}
className="font-medium hover:text-primary"
>
{t.was}
</Link>
<p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="size-3" />
{t.wo}
{t.project && ` · ${t.project}`}
</p>
</TableCell>
<TableCell>
<StatusBadge status={t.status} />
</TableCell>
<TableCell>
<PriorityBadge value={t.priority} />
</TableCell>
<TableCell>{due(t)}</TableCell>
<TableCell>
<Person name={t.assignee?.name || "Teampool"} />
<p className="mt-1 pl-9 text-xs text-muted-foreground">
{t.owningGroup?.name}
</p>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="grid gap-3 md:hidden">{items.map(card)}</div>
</>
)}
<div className="flex gap-3 items-center">
<button
className={buttonClass}
disabled={page === 1}
onClick={() => {
setPage(page - 1);
setSelected([]);
}}
>
Zurück
</button>
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>
Seite {page} / {Math.max(1, tasks.data?.pages || 1)} ·{" "}
{tasks.data?.total || 0} Aufgaben
Seite {page} von {Math.max(1, tasks.data?.pages || 1)}
</span>
<button
className={buttonClass}
disabled={page >= (tasks.data?.pages || 1)}
onClick={() => {
setPage(page + 1);
setSelected([]);
}}
>
Weiter
</button>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={page === 1}
onClick={() => {
setPage(page - 1);
setSelected([]);
}}
>
<ChevronLeft className="mr-1 size-4" />
Zurück
</Button>
<Button
size="sm"
variant="outline"
disabled={page >= (tasks.data?.pages || 1)}
onClick={() => {
setPage(page + 1);
setSelected([]);
}}
>
Weiter
<ChevronRight className="ml-1 size-4" />
</Button>
</div>
</div>
</div>
);

View File

@ -0,0 +1,445 @@
"use client";
import { useState, useRef, type ReactNode } from "react";
import {
AlertCircle,
CheckCircle2,
Circle,
Clock3,
Inbox,
Loader2,
Search,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { useData, type Task } from "./common";
import { cn } from "@/lib/utils";
export function PageHeader({
title,
description,
children,
}: {
title: string;
description?: string;
children?: ReactNode;
}) {
return (
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description && (
<p className="mt-1.5 text-sm text-muted-foreground">{description}</p>
)}
</div>
<div className="flex items-center gap-2">{children}</div>
</div>
);
}
export function EmptyState({
title,
description,
children,
}: {
title: string;
description?: string;
children?: ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center py-14 px-6 text-center">
<div className="mb-4 rounded-xl bg-muted p-3">
<Inbox className="size-6 text-muted-foreground" />
</div>
<h3 className="font-medium">{title}</h3>
{description && (
<p className="mt-2 max-w-md text-sm text-muted-foreground">
{description}
</p>
)}
{children && <div className="mt-5">{children}</div>}
</div>
);
}
export function LoadingRows() {
return (
<div className="space-y-3 p-5" aria-label="Wird geladen">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
);
}
export function StatusBadge({ status }: { status: string }) {
const Icon =
status === "ERLEDIGT"
? CheckCircle2
: status === "IN_BEARBEITUNG"
? Clock3
: Circle;
return (
<Badge
variant="outline"
className={cn(
"gap-1.5 whitespace-nowrap font-normal",
status === "ERLEDIGT"
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300"
: status === "IN_BEARBEITUNG"
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-300"
: "bg-muted/50 text-muted-foreground",
)}
>
<Icon className="size-3" />
{status === "ERLEDIGT"
? "Erledigt"
: status === "IN_BEARBEITUNG"
? "In Arbeit"
: "Offen"}
</Badge>
);
}
export const priorityNames = ["", "Dringend", "Hoch", "Normal", "Niedrig"];
export function PriorityBadge({ value }: { value: number }) {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 text-xs",
value === 1
? "text-red-600 dark:text-red-400"
: "text-muted-foreground",
)}
>
<span className="flex h-3 items-end gap-0.5">
{[1, 2, 3].map((i) => (
<span
key={i}
className={cn(
"w-0.5 rounded-sm",
i <= 5 - value ? "bg-current" : "bg-muted",
)}
style={{ height: `${i * 3 + 2}px` }}
/>
))}
</span>
{priorityNames[value]}
</span>
);
}
export function Person({ name }: { name: string }) {
return (
<span className="inline-flex min-w-0 items-center gap-2">
<Avatar className="size-7">
<AvatarFallback className="bg-primary/10 text-[10px] font-semibold text-primary">
{name
.split(/\s+/)
.slice(0, 2)
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<span className="truncate text-sm">{name}</span>
</span>
);
}
export function dateLabel(value: string) {
return new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
}).format(new Date(value));
}
export function AppSelect({
value,
onValueChange,
options,
label,
name,
disabled,
className,
defaultValue,
}: {
value?: string;
defaultValue?: string;
onValueChange?: (v: string) => void;
options: { value: string; label: string; disabled?: boolean }[];
label: string;
name?: string;
disabled?: boolean;
className?: string;
}) {
return (
<Select
name={name}
value={value === "" ? "__none" : value}
defaultValue={defaultValue === "" ? "__none" : defaultValue}
onValueChange={(v) => onValueChange?.(v === "__none" ? "" : v)}
disabled={disabled}
>
<SelectTrigger aria-label={label} className={cn("h-10", className)}>
<SelectValue placeholder={label} />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem
key={o.value || "__none"}
value={o.value || "__none"}
disabled={o.disabled}
>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export function useConfirm() {
const [state, setState] = useState<{
title: string;
description: string;
label: string;
destructive?: boolean;
} | null>(null);
const resolve = useRef<((value: boolean) => void) | null>(null);
const finish = (value: boolean) => {
resolve.current?.(value);
resolve.current = null;
setState(null);
};
return {
confirm: (
title: string,
description: string,
label = "Ja, löschen",
destructive = true,
) =>
new Promise<boolean>((r) => {
resolve.current = r;
setState({ title, description, label, destructive });
}),
confirmation: (
<AlertDialog
open={!!state}
onOpenChange={(open) => {
if (!open) finish(false);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{state?.title}</AlertDialogTitle>
<AlertDialogDescription>
{state?.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => finish(false)}>
Abbrechen
</AlertDialogCancel>
<Button
variant={state?.destructive ? "destructive" : "default"}
onClick={() => finish(true)}
>
{state?.label}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
),
};
}
export function NameDialog({
title,
description,
initial = "",
open,
onClose,
onSave,
}: {
title: string;
description: string;
initial?: string;
open: boolean;
onClose: () => void;
onSave: (name: string) => Promise<void>;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!v && !busy) onClose();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form
onSubmit={async (e) => {
e.preventDefault();
const name = String(
new FormData(e.currentTarget).get("name"),
).trim();
setBusy(true);
setError("");
try {
await onSave(name);
onClose();
} catch (e) {
setError(
e instanceof Error ? e.message : "Speichern fehlgeschlagen",
);
} finally {
setBusy(false);
}
}}
className="space-y-4"
>
<Input
aria-label="Name"
name="name"
required
maxLength={100}
defaultValue={initial}
autoFocus
/>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={busy}
>
Abbrechen
</Button>
<Button disabled={busy}>
{busy && <Loader2 className="mr-2 size-4 animate-spin" />}
Speichern
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export function TaskPicker({
value,
onChange,
label,
exclude,
group,
}: {
value: string;
onChange: (id: string) => void;
label: string;
exclude?: string;
group?: string;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const query = useData<{ items: Task[] }>(
`/api/tasks?status=ALL&limit=30&q=${encodeURIComponent(search)}${group ? `&group=${encodeURIComponent(group)}` : ""}`,
);
const selected = query.data?.items.find((t) => t.id === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="w-full justify-start font-normal"
aria-label={label}
>
<Search className="mr-2 size-4 shrink-0 text-muted-foreground" />
<span className="truncate">
{selected?.was || (value ? "Verknüpfte Aufgabe" : label)}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[min(380px,calc(100vw-2rem))] p-2"
align="start"
>
<Input
aria-label="Aufgabe suchen"
placeholder="Aufgabe suchen…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="mt-2 max-h-60 overflow-auto">
<Button
type="button"
variant="ghost"
className="w-full justify-start"
onClick={() => {
onChange("");
setOpen(false);
}}
>
Keine Verknüpfung
</Button>
{query.data?.items
.filter((t) => t.id !== exclude)
.map((t) => (
<Button
key={t.id}
type="button"
variant="ghost"
className="h-auto w-full justify-start whitespace-normal text-left"
onClick={() => {
onChange(t.id);
setOpen(false);
}}
>
{t.was}
</Button>
))}
{query.isLoading && <Loader2 className="m-3 size-4 animate-spin" />}
{query.error && (
<p className="p-2 text-sm text-destructive">
<AlertCircle className="mr-1 inline size-4" />
Aufgaben konnten nicht geladen werden.
</p>
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@ -1,17 +1,48 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
import { allowed } from "@/lib/rbac";
import {
request,
useData,
Field,
Notice,
inputClass,
buttonClass,
panelClass,
type Me,
} from "./common";
Plus,
Search,
MoreHorizontal,
Pencil,
Power,
Mail,
ShieldCheck,
Loader2,
KeyRound,
} from "lucide-react";
import { toast } from "sonner";
import { allowed } from "@/lib/rbac";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { request, useData, Field, Notice, type Me } from "./common";
import { PageHeader, Person, EmptyState, LoadingRows, useConfirm } from "./ui";
type User = {
id: string;
name: string;
@ -23,187 +54,329 @@ export function Users() {
const users = useData<User[]>("/api/users");
const me = useData<Me>("/api/me");
const qc = useQueryClient();
const [editing, setEditing] = useState<User | null>(null);
const [show, setShow] = useState(false);
const [editing, setEditing] = useState<User | null | undefined>(undefined);
const [search, setSearch] = useState("");
const [error, setError] = useState<unknown>();
const [busy, setBusy] = useState(false);
async function run(fn: () => Promise<unknown>) {
const { confirm, confirmation } = useConfirm();
async function run(
fn: () => Promise<unknown>,
message = "Änderungen gespeichert",
) {
setBusy(true);
setError(null);
try {
await fn();
await qc.invalidateQueries();
toast.success(message);
} catch (e) {
setError(e);
toast.error(e instanceof Error ? e.message : "Änderung fehlgeschlagen");
} finally {
setBusy(false);
}
}
const rows =
users.data?.filter((u) =>
(u.name + u.email).toLowerCase().includes(search.toLowerCase()),
) || [];
return (
<div className="space-y-5">
<h1 className="text-3xl font-semibold">Benutzer</h1>
<Notice error={error || users.error} />
{allowed(me.data, "users.create") && (
<button
className={buttonClass}
onClick={() => {
setEditing(null);
setShow(true);
}}
>
Benutzer anlegen
</button>
)}
{show && (
<form
key={editing?.id || "new"}
className={panelClass}
onSubmit={(e) => {
e.preventDefault();
const f = new FormData(e.currentTarget);
run(async () => {
const data = {
name: String(f.get("name")),
email: String(f.get("email")),
...(!editing && f.get("password")
? { password: String(f.get("password")) }
: {}),
...(editing && allowed(me.data, "roles.assign")
? { oidcSubject: String(f.get("oidcSubject")) || null }
: {}),
};
await request(
editing ? `/api/users/${editing.id}` : "/api/users",
data,
editing ? "PATCH" : "POST",
);
setShow(false);
});
}}
>
<Field label="Name">
<input
required
name="name"
maxLength={100}
defaultValue={editing?.name}
className={inputClass}
/>
</Field>
<Field label="E-Mail">
<input
required
type="email"
name="email"
defaultValue={editing?.email}
className={inputClass}
/>
</Field>
{!editing && (
<Field label="Einmalpasswort (optional; sonst Einladung versenden)">
<input
type="password"
name="password"
minLength={12}
maxLength={72}
autoComplete="new-password"
className={inputClass}
/>
</Field>
)}
{editing && allowed(me.data, "roles.assign") && (
<Field label="OIDC-Subject (exakte Benutzer-ID des Identity Providers)">
<input
name="oidcSubject"
defaultValue={editing.oidcSubject || ""}
className={inputClass}
/>
</Field>
)}
<p className="text-sm text-muted-foreground">
Neue Benutzer erhalten zunächst keine Aufgabenrechte. Gruppen und
Rollen separat zuweisen.
</p>
<div className="flex gap-2">
<button disabled={busy} className={buttonClass}>
Speichern
</button>
<button
type="button"
className={buttonClass}
onClick={() => setShow(false)}
>
Abbrechen
</button>
</div>
</form>
)}
<div className="grid gap-3">
{users.data?.map((u) => (
<article key={u.id} className={panelClass}>
<h2 className="font-semibold">
{u.name} {!u.active && "(deaktiviert)"}
</h2>
<p>{u.email}</p>
<div className="flex flex-wrap gap-2">
{allowed(me.data, "users.update") && (
<button
className={buttonClass}
onClick={() => {
setEditing(u);
setShow(true);
}}
>
Bearbeiten
</button>
)}
{allowed(me.data, "users.deactivate") && (
<button
disabled={busy}
className={buttonClass}
onClick={() => {
if (
confirm(
`${u.name} ${u.active ? "deaktivieren" : "aktivieren"}?`,
)
)
run(() =>
request(
`/api/users/${u.id}`,
{ active: !u.active },
"PATCH",
),
);
}}
>
{u.active ? "Deaktivieren" : "Aktivieren"}
</button>
)}
{allowed(me.data, "users.reset_password") && (
<button
disabled={busy || !me.data?.smtp || !!u.oidcSubject}
className={buttonClass}
onClick={() =>
run(() =>
request("/api/account", {
action: "invite",
userId: u.id,
}),
)
}
>
Einladung / Passwortlink senden
</button>
)}
</div>
</article>
))}
<div className="space-y-6">
{confirmation}
<PageHeader
title="Benutzer"
description="Konten verwalten und Ihr Team einladen."
>
{allowed(me.data, "users.create") && (
<Button
onClick={() => {
setError(null);
setEditing(null);
}}
>
<Plus className="mr-2 size-4" />
Benutzer anlegen
</Button>
)}
</PageHeader>
<div className="flex items-center justify-between gap-3">
<div className="relative w-full max-w-sm">
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
<Input
className="pl-9"
aria-label="Benutzer suchen"
placeholder="Name oder E-Mail suchen…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Badge variant="secondary">{users.data?.length ?? 0} Konten</Badge>
</div>
<Notice error={users.error} />
<div className="overflow-hidden rounded-xl border bg-card">
{users.isLoading ? (
<LoadingRows />
) : !rows.length ? (
<EmptyState
title="Keine Benutzer gefunden"
description="Versuchen Sie einen anderen Namen oder eine andere E-Mail-Adresse."
/>
) : (
<Table>
<TableHeader>
<TableRow className="bg-muted/40">
<TableHead>Benutzer</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden sm:table-cell">
Anmeldung
</TableHead>
<TableHead className="w-16" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((u) => (
<TableRow key={u.id}>
<TableCell className="py-4">
<Person name={u.name} />
<p className="mt-1 pl-9 text-xs text-muted-foreground">
{u.email}
</p>
</TableCell>
<TableCell>
<Badge
variant={u.active ? "secondary" : "outline"}
className={
u.active
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
: ""
}
>
{u.active ? "Aktiv" : "Deaktiviert"}
</Badge>
</TableCell>
<TableCell className="hidden sm:table-cell">
<span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
<KeyRound className="size-3.5" />
{u.oidcSubject ? "Single Sign-on" : "Passwort"}
</span>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={`${u.name}: Aktionen`}
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{allowed(me.data, "users.update") && (
<DropdownMenuItem
onSelect={() => {
setError(null);
setEditing(u);
}}
>
<Pencil className="mr-2 size-4" />
Bearbeiten
</DropdownMenuItem>
)}
{allowed(me.data, "roles.assign") && (
<DropdownMenuItem asChild>
<Link href="/dashboard/access">
<ShieldCheck className="mr-2 size-4" />
Gruppen & Rechte verwalten
</Link>
</DropdownMenuItem>
)}
{allowed(me.data, "users.reset_password") && (
<DropdownMenuItem
disabled={busy || !me.data?.smtp || !!u.oidcSubject}
onSelect={() =>
run(
() =>
request("/api/account", {
action: "invite",
userId: u.id,
}),
"Passwortlink versendet",
)
}
>
<Mail className="mr-2 size-4" />
Einladung / Passwortlink
</DropdownMenuItem>
)}
{allowed(me.data, "users.deactivate") && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className={u.active ? "text-destructive" : ""}
disabled={busy}
onSelect={async () => {
if (
await confirm(
`${u.active ? "Konto deaktivieren" : "Konto aktivieren"}?`,
u.active
? `${u.name} kann sich nicht mehr anmelden. Bestehende Sitzungen werden beendet; Aufgaben und Historie bleiben erhalten.`
: `${u.name} kann sich wieder anmelden.`,
u.active
? "Ja, deaktivieren"
: "Ja, aktivieren",
u.active,
)
)
run(() =>
request(
`/api/users/${u.id}`,
{ active: !u.active },
"PATCH",
),
);
}}
>
<Power className="mr-2 size-4" />
{u.active ? "Deaktivieren" : "Aktivieren"}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
{!me.data?.smtp && (
<p>
E-Mail-Einladungen werden verfügbar, sobald SMTP eingerichtet ist.
<p className="flex items-start gap-2 text-xs text-muted-foreground">
<Mail className="size-4 shrink-0" />
E-Mail-Einladungen sind noch nicht eingerichtet. Neue Konten können
mit einem Einmalpasswort angelegt werden.
</p>
)}
<Dialog
open={editing !== undefined}
onOpenChange={(v) => {
if (!v && !busy) setEditing(undefined);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editing ? "Benutzer bearbeiten" : "Benutzer anlegen"}
</DialogTitle>
<DialogDescription>
{editing
? "Kontaktdaten und Anmeldeverfahren verwalten."
: "Neue Konten erhalten ihre Rechte anschließend über Gruppen oder Rollen."}
</DialogDescription>
</DialogHeader>
<form
key={editing?.id || "new"}
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
const f = new FormData(e.currentTarget);
run(
async () => {
await request(
editing ? `/api/users/${editing.id}` : "/api/users",
{
name: String(f.get("name")),
email: String(f.get("email")),
...(!editing && f.get("password")
? { password: String(f.get("password")) }
: {}),
...(editing && allowed(me.data, "roles.assign")
? { oidcSubject: String(f.get("oidcSubject")) || null }
: {}),
},
editing ? "PATCH" : "POST",
);
setEditing(undefined);
},
editing ? "Benutzer aktualisiert" : "Benutzer angelegt",
);
}}
>
<Field label="Name">
<Input
required
name="name"
maxLength={100}
defaultValue={editing?.name}
placeholder="Vor- und Nachname"
/>
</Field>
<Field label="E-Mail">
<Input
required
type="email"
name="email"
defaultValue={editing?.email}
placeholder="name@unternehmen.de"
/>
</Field>
{!editing && (
<Field
label={
me.data?.smtp ? "Einmalpasswort (optional)" : "Einmalpasswort"
}
>
<Input
type="password"
name="password"
required={!me.data?.smtp}
minLength={12}
maxLength={72}
autoComplete="new-password"
/>
<span className="text-xs font-normal text-muted-foreground">
Mindestens 12 Zeichen. Wird bei der ersten Anmeldung geändert.
</span>
</Field>
)}
{editing && allowed(me.data, "roles.assign") && (
<details className="rounded-lg border p-3">
<summary className="cursor-pointer text-sm font-medium">
Single Sign-on verknüpfen
</summary>
<div className="mt-3">
<Field label="Benutzerkennung beim Identity Provider">
<Input
name="oidcSubject"
defaultValue={editing.oidcSubject || ""}
/>
<span className="text-xs font-normal text-muted-foreground">
Exakter OIDC-Subject. Die lokale Passwortanmeldung wird
bei Verknüpfung gesperrt.
</span>
</Field>
</div>
</details>
)}
<Notice error={error} />
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setEditing(undefined)}
disabled={busy}
>
Abbrechen
</Button>
<Button disabled={busy}>
{busy && <Loader2 className="mr-2 size-4 animate-spin" />}
Speichern
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -251,9 +251,12 @@ test("UI login, task list and access management render", async ({ page }) => {
).toBeVisible();
await page.goto("/dashboard/access");
await expect(
page.getByRole("heading", { name: "Gruppen, Rollen und Permissions" }),
page.getByRole("heading", { name: "Gruppen & Rechte" }),
).toBeVisible();
await page.getByRole("tab", { name: "Rollen", exact: true }).click();
await expect(
page.getByRole("button", { name: "Systemadministrator", exact: true }),
).toBeVisible();
await expect(page.getByText("Systemadministrator").first()).toBeVisible();
await page.screenshot({
path: "test-results/access-desktop.png",
fullPage: true,
@ -479,3 +482,164 @@ test("linked SSO identities cannot bypass MFA policy with a local password", asy
await api.dispose();
}
});
test("group and role dialogs support real creation, membership, assignment and confirmed deletion", async ({ page }) => {
const api = await login("test-admin");
await page.context().addCookies((await api.storageState()).cookies);
const suffix = Date.now(); const groupName = `UX Team ${suffix}`; const roleName = `UX Leser ${suffix}`;
let groupId = ""; let roleId = "";
try {
await page.goto("/dashboard/access");
await page.getByRole("tab", { name: "Rollen", exact: true }).click();
await page.getByRole("button", { name: "Rolle erstellen", exact: true }).click();
const roleDialog = page.getByRole("dialog", { name: "Rolle erstellen", exact: true });
await roleDialog.getByLabel("Rollenname").fill(roleName);
await roleDialog.getByRole("checkbox", { name: "Aufgaben ansehen", exact: true }).check();
await roleDialog.getByRole("button", { name: "Rolle speichern" }).click();
await expect(roleDialog).not.toBeVisible();
roleId = (await db.accessRole.findUniqueOrThrow({ where: { name: roleName } })).id;
await page.getByRole("tab", { name: "Gruppen", exact: true }).click();
await page.getByRole("button", { name: "Gruppe erstellen", exact: true }).click();
const create = page.getByRole("dialog", { name: "Gruppe erstellen", exact: true });
await create.getByRole("textbox", { name: "Name", exact: true }).fill(groupName);
await create.getByRole("button", { name: "Speichern", exact: true }).click();
await expect(create).not.toBeVisible();
groupId = (await db.group.findUniqueOrThrow({ where: { name: groupName } })).id;
await page.getByRole("button", { name: groupName, exact: true }).click();
let sheet = page.getByRole("dialog", { name: groupName, exact: true });
await sheet.getByRole("button", { name: "Umbenennen" }).click();
const edit = page.getByRole("dialog", { name: "Gruppe bearbeiten", exact: true });
await edit.getByRole("textbox", { name: "Name", exact: true }).fill(groupName + " Neu");
await edit.getByRole("button", { name: "Speichern", exact: true }).click();
await expect(edit).not.toBeVisible();
sheet = page.getByRole("dialog", { name: groupName + " Neu", exact: true });
await sheet.getByRole("checkbox", { name: /test-worker/ }).click();
await expect(sheet.getByRole("checkbox", { name: /test-worker/ })).toBeChecked();
await expect.poll(() => db.userGroup.count({ where: { userId: "test-worker", groupId } })).toBe(1);
await sheet.getByRole("tab", { name: "Rollen", exact: true }).click();
await sheet.getByRole("button", { name: "Rolle zuweisen" }).click();
const assignment = page.getByRole("dialog", { name: "Rolle zuweisen", exact: true });
await assignment.getByRole("combobox", { name: "Rolle", exact: true }).click();
await page.getByRole("option", { name: roleName, exact: true }).click();
await assignment.getByRole("button", { name: "Zuweisen", exact: true }).click();
await expect(assignment).not.toBeVisible();
await expect.poll(() => db.groupRole.count({ where: { groupId, roleId } })).toBe(1);
expect((await api.post("/api/access", { data: { action: "deleteRole", id: roleId } })).status()).toBe(409);
await sheet.getByRole("button", { name: "Gruppe löschen", exact: true }).click();
await page.getByRole("alertdialog").getByRole("button", { name: "Abbrechen" }).click();
expect(await db.group.count({ where: { id: groupId } })).toBe(1);
await sheet.getByRole("button", { name: "Gruppe löschen", exact: true }).click();
await page.getByRole("alertdialog").getByRole("button", { name: "Ja, löschen" }).click();
await expect(sheet).not.toBeVisible();
await expect.poll(() => db.group.count({ where: { id: groupId } })).toBe(0);
expect(await db.userGroup.count({ where: { groupId } })).toBe(0);
expect(await db.groupRole.count({ where: { groupId } })).toBe(0);
await page.getByRole("tab", { name: "Rollen", exact: true }).click();
await page.getByRole("button", { name: `${roleName}: Aktionen`, exact: true }).click();
await page.getByRole("menuitem", { name: "Rolle löschen", exact: true }).click();
await page.getByRole("alertdialog").getByRole("button", { name: "Ja, löschen" }).click();
await expect.poll(() => db.accessRole.count({ where: { id: roleId } })).toBe(0);
} finally {
if (groupId) await db.group.deleteMany({ where: { id: groupId } });
if (roleId) await db.accessRole.deleteMany({ where: { id: roleId } });
await api.dispose();
}
});
test("group deletion protects existing tasks and administrative permissions", async () => {
const admin = await login("test-admin"); const worker = await login("test-worker");
try {
expect((await worker.post("/api/access", { data: { action: "deleteGroup", id: "test-a" } })).status()).toBe(403);
expect((await admin.post("/api/access", { data: { action: "deleteGroup", id: "test-a" } })).status()).toBe(409);
expect((await admin.post("/api/access", { data: { action: "deleteGroup", id: "legacy" } })).status()).toBe(409);
expect((await admin.post("/api/access", { data: { action: "deleteRole", id: "system-admin" } })).status()).toBe(403);
expect(await db.task.count({ where: { id: "test-task-a" } })).toBe(1);
} finally { await admin.dispose(); await worker.dispose(); }
});
test("task dialog preserves planning fields and detail tabs support comments and checklist", async ({ page }) => {
const api = await login("test-admin"); await page.context().addCookies((await api.storageState()).cookies);
const name = `UX Wartung ${Date.now()}`; let taskId = "";
try {
await page.goto("/dashboard/tasks");
await page.getByRole("button", { name: "Neue Aufgabe", exact: true }).click();
const editor = page.getByRole("dialog", { name: "Neue Aufgabe", exact: true });
await editor.getByLabel("Beschreibung", { exact: true }).fill(name);
await editor.getByLabel("Ort / Standort", { exact: true }).fill("Gebäude C");
await editor.getByRole("combobox", { name: "Gruppe", exact: true }).click();
await page.getByRole("option", { name: "test-a", exact: true }).click();
await editor.getByRole("combobox", { name: "Zuständig", exact: true }).click();
await page.getByRole("option", { name: "test-worker", exact: true }).click();
await editor.getByText("Weitere Angaben", { exact: false }).click();
await editor.getByLabel("Projekt", { exact: true }).fill("Prüfung");
await editor.getByLabel("Checkliste (ein Punkt pro Zeile)", { exact: true }).fill("Sichtprüfung\nDokumentieren".replace("\\n", "\n"));
await editor.getByRole("button", { name: "Speichern", exact: true }).click();
await expect(editor).not.toBeVisible();
const task = await db.task.findFirstOrThrow({ where: { was: name } }); taskId = task.id;
expect(task.assigneeId).toBe("test-worker"); expect(task.project).toBe("Prüfung");
await page.goto(`/dashboard/tasks/${taskId}`);
await expect(page.getByRole("heading", { name, exact: true })).toBeVisible();
await page.getByRole("textbox", { name: "Kommentar", exact: true }).fill("Prüfung ist vorbereitet.");
await page.getByRole("button", { name: "Kommentieren", exact: true }).click();
await expect(page.getByText("Prüfung ist vorbereitet.", { exact: true })).toBeVisible();
await page.getByRole("checkbox", { name: /Sichtprüfung/ }).click();
await expect(page.getByRole("checkbox", { name: /Sichtprüfung/ })).toBeChecked();
await expect.poll(async () => ((await db.task.findUniqueOrThrow({ where: { id: taskId } })).checklist as { done: boolean }[])[0].done).toBe(true);
await page.getByRole("tab", { name: /Dateien/ }).click();
await expect(page.getByText("Datei auswählen und hochladen")).toBeVisible();
await page.getByRole("tab", { name: "Verknüpfungen" }).click();
await expect(page.getByRole("button", { name: "Voraussetzung auswählen", exact: true })).toBeVisible();
} finally { if (taskId) await db.task.deleteMany({ where: { id: taskId } }); await api.dispose(); }
});
test("user dialog creates and edits accounts and confirms deactivation", async ({ page }) => {
const api = await login("test-admin");
await page.context().addCookies((await api.storageState()).cookies);
const email = `ux-${Date.now()}@example.test`;
try {
await page.goto("/dashboard/users");
await page.getByRole("button", { name: "Benutzer anlegen", exact: true }).click();
let dialog = page.getByRole("dialog");
await dialog.getByLabel("Name", { exact: true }).fill("UX Benutzer");
await dialog.getByLabel("E-Mail", { exact: true }).fill(email);
await dialog.getByLabel(/Einmalpasswort/).fill("Temporary-test-2026!");
await dialog.getByRole("button", { name: /Speichern|Anlegen/ }).click();
await expect(dialog).not.toBeVisible();
await page.getByRole("textbox", { name: "Benutzer suchen" }).fill(email);
await page.getByRole("button", { name: "UX Benutzer: Aktionen" }).click();
await page.getByRole("menuitem", { name: "Bearbeiten", exact: true }).click();
dialog = page.getByRole("dialog");
await dialog.getByLabel("Name", { exact: true }).fill("UX Geändert");
await dialog.getByRole("button", { name: /Speichern/ }).click();
await expect(dialog).not.toBeVisible();
await page.getByRole("button", { name: "UX Geändert: Aktionen" }).click();
await page.getByRole("menuitem", { name: "Deaktivieren", exact: true }).click();
await page.getByRole("alertdialog").getByRole("button", { name: "Ja, deaktivieren" }).click();
await expect.poll(async () => (await db.user.findUniqueOrThrow({ where: { email } })).active).toBe(false);
await expect(page.getByText("Deaktiviert", { exact: true })).toBeVisible();
} finally {
await db.user.deleteMany({ where: { email } });
await api.dispose();
}
});
test("import preview does not write tasks until explicitly imported", async ({ page }) => {
const api = await login("test-admin");
await page.context().addCookies((await api.storageState()).cookies);
const name = `UX Import ${Date.now()}`;
try {
await page.goto("/dashboard/settings");
await page.getByText("CSV direkt einfügen & Dateiformat", { exact: true }).click();
await page.getByRole("textbox", { name: "CSV-Inhalt" }).fill(`wo;was;bisWann;owningGroupId;priority\nWerkstatt;${name};2026-12-01;test-a;2`);
await page.getByRole("button", { name: /Vorschau/ }).click();
await expect(page.getByText("Vorschau – noch nichts angelegt", { exact: true })).toBeVisible();
expect(await db.task.count({ where: { was: name } })).toBe(0);
await page.getByRole("button", { name: "Import ausführen", exact: true }).click();
await expect(page.getByText("Importergebnis", { exact: true })).toBeVisible();
expect(await db.task.count({ where: { was: name } })).toBe(1);
} finally {
await db.task.deleteMany({ where: { was: name } });
await api.dispose();
}
});