Konzept (abgestimmt): Beim Einreichen wählt der Einreicher einen konkreten Freigeber; die Freigabe/Ablehnung erfolgt im neuen Aufgaben-Modul (Vier-Augen). - Modell: generisches Task + TaskComment (Migration inkl. RLS; Task/TaskComment in TENANT_MODELS). Erster Typ policy_approval. Neues Modul „tasks" (lib/modules, Nav, i18n) — für Bestands-Tenants per Default aktiv. - policies.ts: submitForApproval(code, formData) verlangt einen Freigeber (aktiver Nutzer mit policy:approve, ≠ Einreicher), setzt IN_FREIGABE und erzeugt eine Aufgabe; Resubmit schließt alte offene Aufgaben. Alte approvePolicy/rejectPolicy entfernt (wandern ins Aufgaben-Modul). - actions/tasks.ts: approveTask/rejectTask (nur zugewiesener Freigeber, ≠ Einreicher; Richtlinie → FREIGEGEBEN bzw. zurück auf ENTWURF) und commentTask (nur Beteiligte); Kommentare/Statuswechsel historisiert, Audit. - /tasks: Aufgaben des Nutzers (offen/erledigt) mit Freigeben/Ablehnen (Grund erforderlich)/Kommentieren + Verlauf. Richtlinien-Editor: Freigeber-Dropdown beim Einreichen, „Zur Freigabe bei …" + Link zur Aufgabe (keine Inline-Freigabe mehr). - Dashboard: Kachel „N offene Aufgabe(n)" verlinkt auf /tasks. - Seed: zweiter ISB „Bea Approver" als Freigeber (ermöglicht den Vier-Augen-Flow). Browser-verifiziert: R08 eingereicht (Freigeber Bea) → Aufgabe; Dashboard-Kachel bei Bea; Freigeben in /tasks → R08 FREIGEGEBEN, Task DONE, Kommentar-Historie (submit+approve). tsc + lint + build + Guard-Check grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
119 lines
5.2 KiB
TypeScript
119 lines
5.2 KiB
TypeScript
import Link from "next/link";
|
|
import { Check, X } from "lucide-react";
|
|
import { requireSession } from "@/server/auth";
|
|
import { dbForTenant } from "@/server/db";
|
|
import { PageHead, Pill } from "@/components/mockup-ui";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { approveTask, rejectTask, commentTask } from "@/server/actions/tasks";
|
|
|
|
const STATUS_TONE: Record<string, "ok" | "info" | "warn" | "mut"> = { OPEN: "info", DONE: "ok", REJECTED: "warn", CANCELLED: "mut" };
|
|
const STATUS_LABEL: Record<string, string> = { OPEN: "Offen", DONE: "Freigegeben", REJECTED: "Abgelehnt", CANCELLED: "Zurückgezogen" };
|
|
const KIND_LABEL: Record<string, string> = { submit: "eingereicht", approve: "freigegeben", reject: "abgelehnt", comment: "Kommentar" };
|
|
|
|
interface TaskWithComments {
|
|
id: string; type: string; title: string; status: string;
|
|
entityType: string | null; entityRef: string | null;
|
|
assigneeId: string | null; createdById: string | null; createdAt: Date;
|
|
comments: { id: string; authorId: string | null; kind: string; body: string; createdAt: Date }[];
|
|
}
|
|
|
|
function entityHref(t: TaskWithComments): string | null {
|
|
return t.entityType === "policy_document" && t.entityRef ? `/policies/${t.entityRef}` : null;
|
|
}
|
|
|
|
function TaskCard({ task, me, name }: { task: TaskWithComments; me: string; name: (id: string | null) => string }) {
|
|
const isAssignee = task.assigneeId === me;
|
|
const isOpen = task.status === "OPEN";
|
|
const href = entityHref(task);
|
|
return (
|
|
<div className="shadow-card rounded-xl border bg-card p-4">
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
<div>
|
|
<p className="font-heading text-sm font-semibold">
|
|
{href ? <Link href={href} className="hover:underline">{task.title}</Link> : task.title}
|
|
</p>
|
|
<p className="text-[11.5px] text-muted-foreground">
|
|
Eingereicht von {name(task.createdById)} · Freigeber {name(task.assigneeId)} · {task.createdAt.toLocaleDateString("de-DE")}
|
|
</p>
|
|
</div>
|
|
<Pill tone={STATUS_TONE[task.status] ?? "mut"}>{STATUS_LABEL[task.status] ?? task.status}</Pill>
|
|
</div>
|
|
|
|
{task.comments.length > 0 && (
|
|
<ul className="mt-3 space-y-1.5 border-t pt-3">
|
|
{task.comments.map((c) => (
|
|
<li key={c.id} className="text-[12.5px]">
|
|
<span className="text-muted-foreground">{name(c.authorId)} · {KIND_LABEL[c.kind] ?? c.kind}:</span> {c.body}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{isOpen && isAssignee && (
|
|
<div className="mt-3 grid gap-2 border-t pt-3 sm:grid-cols-2">
|
|
<form action={approveTask.bind(null, task.id)} className="flex gap-2">
|
|
<Input name="note" placeholder="Kommentar (optional)" className="h-8" />
|
|
<Button type="submit" size="sm"><Check className="size-4" /> Freigeben</Button>
|
|
</form>
|
|
<form action={rejectTask.bind(null, task.id)} className="flex gap-2">
|
|
<Input name="note" placeholder="Grund (erforderlich)" required className="h-8" />
|
|
<Button type="submit" variant="outline" size="sm"><X className="size-4" /> Ablehnen</Button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
{isOpen && !isAssignee && (
|
|
<p className="mt-3 border-t pt-3 text-[11.5px] text-muted-foreground">Wartet auf Freigabe durch {name(task.assigneeId)}.</p>
|
|
)}
|
|
|
|
<form action={commentTask.bind(null, task.id)} className="mt-2 flex gap-2">
|
|
<Input name="body" placeholder="Kommentar hinzufügen…" className="h-8" />
|
|
<Button type="submit" variant="ghost" size="sm">Kommentieren</Button>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default async function TasksPage() {
|
|
const session = await requireSession();
|
|
const db = dbForTenant(session.user.tenantId);
|
|
const me = session.user.id;
|
|
|
|
const [tasks, users] = await Promise.all([
|
|
db.task.findMany({
|
|
where: { OR: [{ assigneeId: me }, { createdById: me }] },
|
|
include: { comments: { orderBy: { createdAt: "asc" } } },
|
|
orderBy: [{ createdAt: "desc" }],
|
|
}),
|
|
db.user.findMany({ select: { id: true, name: true } }),
|
|
]);
|
|
const name = (id: string | null) => users.find((u) => u.id === id)?.name ?? "—";
|
|
|
|
const open = tasks.filter((t) => t.status === "OPEN");
|
|
const resolved = tasks.filter((t) => t.status !== "OPEN");
|
|
const myOpen = open.filter((t) => t.assigneeId === me);
|
|
|
|
return (
|
|
<main className="flex-1 p-6">
|
|
<PageHead crumb="Aufgaben" title="Aufgaben & Freigaben" sub={`${myOpen.length} offene Freigabe(n) für Sie · ${open.length} offen gesamt`} />
|
|
|
|
<section className="mt-4">
|
|
<h2 className="mb-3 font-heading text-sm font-semibold">Offen</h2>
|
|
<div className="space-y-3">
|
|
{open.length === 0 && <p className="text-sm text-muted-foreground">Keine offenen Aufgaben.</p>}
|
|
{open.map((t) => <TaskCard key={t.id} task={t} me={me} name={name} />)}
|
|
</div>
|
|
</section>
|
|
|
|
{resolved.length > 0 && (
|
|
<section className="mt-8">
|
|
<h2 className="mb-3 font-heading text-sm font-semibold">Erledigt</h2>
|
|
<div className="space-y-3">
|
|
{resolved.map((t) => <TaskCard key={t.id} task={t} me={me} name={name} />)}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|