feat(zuweisungen): Matrix Schluessel x Host mit CSV-Export (#025)
Bisher liess sich nur je Schluessel oder je Host nachsehen, wo ein Key verwaltet ist. Die Matrix zeigt das auf einen Blick, loest Hostgruppen-Zuweisungen je Mitglied auf (P vs. P·G) und macht nicht synchrone Zustaende ohne Farbe erkennbar. Die Matrix plant nur: Zuweisen und Entfernen laufen ueber die vorhandenen Aktionen, ausgerollt wird weiter per Dry-Run auf der Schluesselseite. Der Aufbau ist eine reine Funktion in src/lib, damit Seite und CSV-Export garantiert dasselbe zeigen und die Gruppenaufloesung ohne Datenbank testbar ist. Der CSV-Export entschaerft Formelbeginne, weil Schluesselnamen frei waehlbar sind und Excel sie sonst ausfuehren wuerde. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { EintragAktionen } from "@/components/ausrollen/EintragAktionen";
|
||||
import { PublicZuweisenFormular } from "@/components/ausrollen/PublicZuweisenFormular";
|
||||
import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { InnereBox } from "@/components/ui/Karte";
|
||||
import { KENNUNG_TEXT, type MatrixHost, type MatrixSchluessel, type Zelle } from "@/lib/zuweisungsmatrix";
|
||||
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||
|
||||
export type ZellenAuswahl = { readonly schluesselId: string; readonly hostId: string | null };
|
||||
|
||||
/**
|
||||
* Detail einer Matrixzelle (#025): was dort verwaltet ist, Entfernen und Zuweisen mit den
|
||||
* vorhandenen Aktionen. Ausgerollt wird weiter nur ueber den Dry-Run der Schluesselseite –
|
||||
* die Matrix plant, sie schreibt nichts auf Hosts.
|
||||
*
|
||||
* Die Zelle kommt aus den Props: nach `router.refresh()` zeigt der offene Dialog sofort den
|
||||
* neuen Stand, ohne eigenen Zustand nachzufuehren.
|
||||
*/
|
||||
export function ZellenDialog({
|
||||
schluessel,
|
||||
host,
|
||||
zelle,
|
||||
darfZuweisen,
|
||||
darfPrivatEntfernen,
|
||||
auswahl,
|
||||
schliessen,
|
||||
}: {
|
||||
schluessel: MatrixSchluessel;
|
||||
host: MatrixHost | undefined;
|
||||
zelle: Zelle | undefined;
|
||||
darfZuweisen: boolean;
|
||||
darfPrivatEntfernen: boolean;
|
||||
auswahl?: ZielAuswahl;
|
||||
schliessen: () => void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const router = useRouter();
|
||||
// Nach erfolgreichem Zuweisen ein frisches Formular – sonst schickt ein zweiter Klick
|
||||
// dieselbe Zuweisung noch einmal und erntet „bereits zugewiesen“.
|
||||
const [formularNr, setFormularNr] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const element = dialog.current;
|
||||
if (element && !element.open) element.showModal();
|
||||
}, []);
|
||||
|
||||
const titel = host ? `${schluessel.name} auf ${host.name}` : schluessel.name;
|
||||
const vorauswahl = host && auswahl?.hosts.some((h) => h.id === host.id) ? `host:${host.id}` : undefined;
|
||||
const eintraege = zelle?.eintraege ?? [];
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
aria-label={titel}
|
||||
onClose={schliessen}
|
||||
className="border-border-strong bg-surface text-text rounded-karte m-auto max-h-[calc(100dvh-32px)] w-[calc(100%-32px)] overflow-y-auto overscroll-contain max-w-xl border p-5 backdrop:bg-black/70 sm:p-6"
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-display text-[18px] font-semibold break-words">{schluessel.name}</h2>
|
||||
<p className="text-muted mt-0.5 text-[13.5px]">
|
||||
{host ? (
|
||||
<>
|
||||
auf <span className="font-mono">{host.name}</span>
|
||||
</>
|
||||
) : (
|
||||
"Einem Host oder einer Hostgruppe zuweisen"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={schliessen}
|
||||
aria-label="Schließen"
|
||||
className="text-muted hover:text-text hover:bg-raised rounded-feld -mt-2 -mr-2 flex size-11 shrink-0 items-center justify-center"
|
||||
>
|
||||
<X className="size-5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-[13.5px]">
|
||||
<Link
|
||||
href={`/schluessel/${schluessel.id}`}
|
||||
className="text-accent inline-flex min-h-11 items-center hover:underline"
|
||||
>
|
||||
Zum Schlüssel
|
||||
</Link>
|
||||
{host && (
|
||||
<Link
|
||||
href={`/hosts/${host.id}`}
|
||||
className="text-accent inline-flex min-h-11 items-center hover:underline"
|
||||
>
|
||||
Zum Host
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{host && (
|
||||
<section className="mt-3 flex flex-col gap-3" aria-label="Verwendungen in dieser Zelle">
|
||||
{eintraege.length === 0 ? (
|
||||
<p className="text-muted border-border-strong rounded-box border border-dashed px-4 py-4 text-center text-[14px]">
|
||||
Auf diesem Host ist der Schlüssel nicht zugewiesen.
|
||||
</p>
|
||||
) : (
|
||||
eintraege.map((e) => (
|
||||
<InnereBox
|
||||
key={`${e.art}-${e.verwendungId}`}
|
||||
className="flex items-start justify-between gap-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<p className="text-[14px] font-medium">
|
||||
{KENNUNG_TEXT[e.kennung]}
|
||||
{e.gruppe && <> „{e.gruppe.name}“</>}
|
||||
</p>
|
||||
<p className="text-muted text-[13px]">
|
||||
Zielbenutzer <span className="text-text font-mono">{e.zielbenutzer}</span>
|
||||
</p>
|
||||
{e.art === "public" ? (
|
||||
<p className="text-muted text-[13px] break-all">
|
||||
Optionen <span className="text-text font-mono text-[12.5px]">{e.optionen ?? "–"}</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted text-[13px] break-all">
|
||||
Pfad <span className="text-text font-mono text-[12.5px]">{e.pfad}</span>
|
||||
</p>
|
||||
)}
|
||||
<VerwendungsZustand zustand={e.zustand} />
|
||||
{e.gruppe && darfZuweisen && (
|
||||
<p className="text-muted text-[12.5px]">
|
||||
Entfernen gilt für alle Hosts der Gruppe „{e.gruppe.name}“.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<EintragAktionen
|
||||
art={e.art}
|
||||
verwendungId={e.verwendungId}
|
||||
schluesselId={schluessel.id}
|
||||
darfEntfernen={
|
||||
e.zustand !== "ENTFERNEN" && (e.art === "public" ? darfZuweisen : darfPrivatEntfernen)
|
||||
}
|
||||
testbar={false}
|
||||
/>
|
||||
</InnereBox>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{darfZuweisen && auswahl && (
|
||||
<section className="border-border mt-5 flex flex-col gap-3 border-t pt-5">
|
||||
<h3 className="text-[15px] font-semibold">Public Key zuweisen</h3>
|
||||
<PublicZuweisenFormular
|
||||
key={formularNr}
|
||||
schluesselId={schluessel.id}
|
||||
auswahl={auswahl}
|
||||
{...(vorauswahl ? { vorauswahl } : {})}
|
||||
beiErfolg={() => {
|
||||
setFormularNr((n) => n + 1);
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{darfZuweisen && (
|
||||
<div className="border-border mt-5 flex flex-col gap-2 border-t pt-4">
|
||||
<p className="text-muted text-[13px]">
|
||||
Zuweisen und Entfernen ändern auf dem Host noch nichts – erst der Dry-Run auf der Schlüsselseite
|
||||
zeigt und schreibt die Änderung.
|
||||
</p>
|
||||
<Button
|
||||
href={`/schluessel/${schluessel.id}`}
|
||||
klein
|
||||
className="self-start"
|
||||
icon={<ArrowRight className="size-4" />}
|
||||
>
|
||||
Zum Dry-Run
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user