Redesign workspace with shadcn workflows and complete group management
This commit is contained in:
parent
813ee735c6
commit
94d29850c6
@ -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
24
UX-ABNAHME.md
Normal 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
112
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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); }
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
27
src/components/ui/checkbox.tsx
Normal file
27
src/components/ui/checkbox.tsx
Normal 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";
|
||||
@ -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>
|
||||
|
||||
@ -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
@ -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
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
445
src/components/workspace/ui.tsx
Normal file
445
src/components/workspace/ui.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user