Merge branch 'phase/025-zuweisungsmatrix'
This commit is contained in:
@@ -14,10 +14,13 @@ export function PublicZuweisenFormular({
|
||||
schluesselId,
|
||||
auswahl,
|
||||
beiErfolg,
|
||||
vorauswahl,
|
||||
}: {
|
||||
schluesselId: string;
|
||||
auswahl: ZielAuswahl;
|
||||
beiErfolg?: () => void;
|
||||
/** Vorausgewaehltes Ziel als „host:<id>“ oder „gruppe:<id>“, z. B. aus der Matrix (#025). */
|
||||
vorauswahl?: string;
|
||||
}) {
|
||||
const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => {
|
||||
const ergebnis = await publicZuweisenAktion(vorher, formular);
|
||||
@@ -28,7 +31,7 @@ export function PublicZuweisenFormular({
|
||||
<form onSubmit={absenden} className="flex flex-col gap-4">
|
||||
<input type="hidden" name="schluesselId" value={schluesselId} />
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
<Auswahlliste beschriftung="Ziel" name="ziel" defaultValue="" required>
|
||||
<Auswahlliste beschriftung="Ziel" name="ziel" defaultValue={vorauswahl ?? ""} required>
|
||||
<option value="" disabled>
|
||||
Host oder Hostgruppe wählen …
|
||||
</option>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { VerwendungsZustand as Zustand } from "@/generated/prisma/enums";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/** Zustand einer Verwendung – immer Punkt und Text, nie nur Farbe (#016). */
|
||||
const ANZEIGE: Record<Zustand, { text: string; klasse: string; punkt: string }> = {
|
||||
export const ZUSTAND_ANZEIGE: Record<Zustand, { text: string; klasse: string; punkt: string }> = {
|
||||
GEPLANT: { text: "noch nicht ausgerollt", klasse: "text-accent", punkt: "○" },
|
||||
AUSGEROLLT: { text: "synchron", klasse: "text-ok", punkt: "●" },
|
||||
ABWEICHUNG: { text: "Drift", klasse: "text-danger", punkt: "●" },
|
||||
@@ -12,7 +12,7 @@ const ANZEIGE: Record<Zustand, { text: string; klasse: string; punkt: string }>
|
||||
};
|
||||
|
||||
export function VerwendungsZustand({ zustand, className }: { zustand: Zustand; className?: string }) {
|
||||
const anzeige = ANZEIGE[zustand];
|
||||
const anzeige = ZUSTAND_ANZEIGE[zustand];
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { ZUSTAND_ANZEIGE } from "@/components/ausrollen/VerwendungsZustand";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import {
|
||||
KENNUNG_TEXT,
|
||||
ZUSTAND_KURZ,
|
||||
ZUSTAND_TEXT,
|
||||
zellenSchluessel,
|
||||
type MatrixHost,
|
||||
type MatrixSchluessel,
|
||||
type Zelle,
|
||||
} from "@/lib/zuweisungsmatrix";
|
||||
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||
import { ZellenDialog, type ZellenAuswahl } from "./ZellenDialog";
|
||||
|
||||
/**
|
||||
* Matrix Schluessel × Host (#025). Ab md eine Tabelle mit eigenem Scrollbereich – die Seite
|
||||
* selbst scrollt nie seitwaerts –, darunter eine Liste je Schluessel, weil auf 390 px keine
|
||||
* zweite Spalte neben den Schluesselnamen passt.
|
||||
*/
|
||||
export type MatrixProps = {
|
||||
zeilen: readonly MatrixSchluessel[];
|
||||
spalten: readonly MatrixHost[];
|
||||
/** Nur belegte Zellen, Schluessel `${schluesselId}:${hostId}`. */
|
||||
zellen: Readonly<Record<string, Zelle>>;
|
||||
darfZuweisen: boolean;
|
||||
darfPrivatEntfernen: boolean;
|
||||
auswahl?: ZielAuswahl;
|
||||
};
|
||||
|
||||
/** Langtext fuer title und Screenreader: Art, Gruppe, Benutzer und Zustand je Eintrag. */
|
||||
function zellenText(zelle: Zelle): string {
|
||||
return zelle.eintraege
|
||||
.map(
|
||||
(e) =>
|
||||
`${KENNUNG_TEXT[e.kennung]}${e.gruppe ? ` ${e.gruppe.name}` : ""}, Benutzer ${e.zielbenutzer}, ${ZUSTAND_TEXT[e.zustand]}`,
|
||||
)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function zielbenutzerKurz(zelle: Zelle): string {
|
||||
const [erster, ...rest] = zelle.zielbenutzer;
|
||||
return rest.length > 0 ? `${erster} +${rest.length}` : (erster ?? "");
|
||||
}
|
||||
|
||||
export function Zuweisungsmatrix({
|
||||
zeilen,
|
||||
spalten,
|
||||
zellen,
|
||||
darfZuweisen,
|
||||
darfPrivatEntfernen,
|
||||
auswahl,
|
||||
}: MatrixProps) {
|
||||
const [offen, setOffen] = useState<ZellenAuswahl>();
|
||||
const zuweisbar = (s: MatrixSchluessel) => darfZuweisen && !!auswahl && s.kategorie !== "DEPLOYMENT";
|
||||
|
||||
const schluessel = offen ? zeilen.find((s) => s.id === offen.schluesselId) : undefined;
|
||||
const host = offen?.hostId ? spalten.find((h) => h.id === offen.hostId) : undefined;
|
||||
const zelle = offen?.hostId ? zellen[zellenSchluessel(offen.schluesselId, offen.hostId)] : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: Kopfzeile und erste Spalte bleiben beim Scrollen stehen. Der Scrollbereich
|
||||
braucht eine eigene Hoehe, sonst klebt die Kopfzeile am Container statt am Fenster. */}
|
||||
<div className="border-border bg-surface relative hidden max-h-[calc(100dvh-11rem)] overflow-auto rounded-[12px] border md:block">
|
||||
<table className="min-w-full border-separate border-spacing-0 text-left text-[13px]">
|
||||
<caption className="sr-only">
|
||||
Zuweisungen: Schlüssel in den Zeilen, Hosts in den Spalten. Eine Zelle öffnet die Details.
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="bg-surface border-border-strong text-muted sticky top-0 left-0 z-30 min-w-56 border-r border-b px-3 py-2.5 text-[13px] font-medium"
|
||||
>
|
||||
Schlüssel
|
||||
</th>
|
||||
{spalten.map((h) => (
|
||||
<th
|
||||
key={h.id}
|
||||
scope="col"
|
||||
className="bg-surface border-border-strong sticky top-0 z-20 border-b px-1 py-2.5 align-bottom font-medium"
|
||||
>
|
||||
<Link
|
||||
href={`/hosts/${h.id}`}
|
||||
title={h.name}
|
||||
className="text-muted hover:text-accent block max-w-28 truncate px-1.5 font-mono text-[12.5px] transition-colors"
|
||||
>
|
||||
{h.name}
|
||||
</Link>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{zeilen.map((s) => (
|
||||
<tr key={s.id} className="group">
|
||||
<th
|
||||
scope="row"
|
||||
className="bg-surface border-border group-hover:bg-raised sticky left-0 z-10 max-w-72 border-r border-b px-3 py-1.5 font-normal transition-colors"
|
||||
>
|
||||
<Link
|
||||
href={`/schluessel/${s.id}`}
|
||||
title={s.name}
|
||||
className="text-text hover:text-accent block truncate text-[13.5px] font-medium transition-colors"
|
||||
>
|
||||
{s.name}
|
||||
</Link>
|
||||
{s.person && <span className="text-muted block truncate text-[12px]">{s.person.name}</span>}
|
||||
</th>
|
||||
{spalten.map((h) => {
|
||||
const z = zellen[zellenSchluessel(s.id, h.id)];
|
||||
return (
|
||||
<td key={h.id} className="border-border border-b p-0.5 align-middle">
|
||||
{z ? (
|
||||
<BelegteZelle
|
||||
zelle={z}
|
||||
schluessel={s}
|
||||
host={h}
|
||||
oeffnen={() => setOffen({ schluesselId: s.id, hostId: h.id })}
|
||||
/>
|
||||
) : zuweisbar(s) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOffen({ schluesselId: s.id, hostId: h.id })}
|
||||
title={`${s.name} auf ${h.name} zuweisen`}
|
||||
className="text-border-strong hover:text-accent hover:bg-raised rounded-feld flex min-h-11 w-full min-w-20 items-center justify-center transition-colors"
|
||||
>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="sr-only">
|
||||
{s.name} auf {h.name}: nicht zugewiesen – Details und Zuweisen
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-border-strong flex min-h-11 min-w-20 items-center justify-center">
|
||||
<span aria-hidden>·</span>
|
||||
<span className="sr-only">nicht zugewiesen</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Telefon: je Schluessel nur die Hosts, auf denen er verwaltet ist. */}
|
||||
<ul className="flex flex-col gap-3 md:hidden">
|
||||
{zeilen.map((s) => {
|
||||
const belegt = spalten.flatMap((h) => {
|
||||
const z = zellen[zellenSchluessel(s.id, h.id)];
|
||||
return z ? [{ host: h, zelle: z }] : [];
|
||||
});
|
||||
return (
|
||||
<li key={s.id} className="border-border bg-surface rounded-karte min-w-0 border p-4">
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/schluessel/${s.id}`}
|
||||
className="text-text hover:text-accent block truncate text-[14.5px] font-medium"
|
||||
>
|
||||
{s.name}
|
||||
</Link>
|
||||
<span className="text-muted block text-[12.5px]">
|
||||
{belegt.length === 1 ? "1 Host" : `${belegt.length} Hosts`}
|
||||
{s.person && <> · {s.person.name}</>}
|
||||
</span>
|
||||
</div>
|
||||
{zuweisbar(s) && (
|
||||
<Button
|
||||
klein
|
||||
icon={<Plus className="size-4" />}
|
||||
onClick={() => setOffen({ schluesselId: s.id, hostId: null })}
|
||||
>
|
||||
Zuweisen
|
||||
<span className="sr-only"> – {s.name}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{belegt.length > 0 && (
|
||||
<ul className="mt-3 flex flex-col">
|
||||
{belegt.map(({ host: h, zelle: z }) => {
|
||||
const anzeige = ZUSTAND_ANZEIGE[z.zustand];
|
||||
return (
|
||||
<li key={h.id} className="border-border border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOffen({ schluesselId: s.id, hostId: h.id })}
|
||||
className="hover:bg-raised/60 rounded-box -mx-2 flex min-h-11 w-[calc(100%+1rem)] min-w-0 items-center justify-between gap-3 px-2 py-2 text-left"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="text-text block truncate font-mono text-[13px]">{h.name}</span>
|
||||
<span className="text-muted block truncate text-[12.5px]">
|
||||
{z.kennungen.join(" ")} ·{" "}
|
||||
<span className="font-mono">{zielbenutzerKurz(z)}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex shrink-0 items-center gap-1.5 text-[13px] whitespace-nowrap",
|
||||
anzeige.klasse,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden>{anzeige.punkt}</span>
|
||||
{ZUSTAND_TEXT[z.zustand]}
|
||||
</span>
|
||||
<span className="sr-only">: {zellenText(z)}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{offen && schluessel && (
|
||||
<ZellenDialog
|
||||
schluessel={schluessel}
|
||||
host={host}
|
||||
zelle={zelle}
|
||||
darfZuweisen={zuweisbar(schluessel)}
|
||||
darfPrivatEntfernen={darfPrivatEntfernen}
|
||||
{...(auswahl ? { auswahl } : {})}
|
||||
schliessen={() => setOffen(undefined)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BelegteZelle({
|
||||
zelle,
|
||||
schluessel,
|
||||
host,
|
||||
oeffnen,
|
||||
}: {
|
||||
zelle: Zelle;
|
||||
schluessel: MatrixSchluessel;
|
||||
host: MatrixHost;
|
||||
oeffnen: () => void;
|
||||
}) {
|
||||
const anzeige = ZUSTAND_ANZEIGE[zelle.zustand];
|
||||
const text = zellenText(zelle);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={oeffnen}
|
||||
title={text}
|
||||
className="hover:bg-raised rounded-feld flex min-h-11 w-full min-w-20 flex-col items-start justify-center gap-0.5 px-2 py-1 text-left transition-colors"
|
||||
>
|
||||
<span aria-hidden className="flex items-center gap-1.5 whitespace-nowrap">
|
||||
<span className={anzeige.klasse}>{anzeige.punkt}</span>
|
||||
<span className="text-text font-medium">{zelle.kennungen.join(" ")}</span>
|
||||
<span className={clsx("text-[11.5px]", anzeige.klasse)}>{ZUSTAND_KURZ[zelle.zustand]}</span>
|
||||
</span>
|
||||
<span aria-hidden className="text-muted block max-w-24 truncate font-mono text-[11.5px]">
|
||||
{zielbenutzerKurz(zelle)}
|
||||
</span>
|
||||
<span className="sr-only">
|
||||
{schluessel.name} auf {host.name}: {text}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user