Files
Laufzettel/src/components/schnellstart-pflege.tsx
T
florian.wilde f5936327a0
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(schnellstart): Kacheln je Geraetetyp in der Oberflaeche pflegen (#44)
2026-10-04 15:41:47 +00:00

168 lines
6.7 KiB
TypeScript

"use client";
import { useActionState, useState } from "react";
import { ArrowDown, ArrowUp, Plus, X } from "lucide-react";
import type { GeraeteTyp } from "@/generated/prisma/enums";
import { GERAETE_TYP_TEXT } from "@/lib/geraete-text";
import { schnellstartSpeichernAktion, type AktionsErgebnis } from "@/server/actions/schnellstart";
export type Eintrag = {
readonly vorlageId: string;
readonly name: string;
readonly kategorie: string | null;
readonly kundeName: string | null;
};
/**
* Schnellstart eines Geraetetyps pflegen (#022).
*
* Die Reihenfolge laeuft ueber Hoch und Runter, nicht ueber Drag & Drop. Der
* Editor benutzt dnd-kit, weil dort hundert Schritte in Abschnitten liegen; hier
* sind es zwei bis vier Kacheln, und zwei Knoepfe sind am Handy verlaesslicher
* als eine Ziehgeste neben einer scrollenden Seite.
*
* Gespeichert wird die **ganze** Liste als ein Feld, eine Kennung je Zeile. So
* gibt es eine Wahrheit ueber die Reihenfolge: die des Formulars.
*/
export function SchnellstartPflege({
typ,
zugewiesen,
auswahl,
}: {
typ: GeraeteTyp;
zugewiesen: readonly Eintrag[];
auswahl: readonly Eintrag[];
}) {
const [liste, setListe] = useState<readonly Eintrag[]>(zugewiesen);
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
schnellstartSpeichernAktion,
{},
);
const offen = auswahl.filter(
(eintrag) => !liste.some((gewaehlt) => gewaehlt.vorlageId === eintrag.vorlageId),
);
function verschieben(stelle: number, richtung: -1 | 1) {
const ziel = stelle + richtung;
if (ziel < 0 || ziel >= liste.length) return;
const kopie = [...liste];
const [heraus] = kopie.splice(stelle, 1);
if (heraus) kopie.splice(ziel, 0, heraus);
setListe(kopie);
}
const geaendert =
liste.length !== zugewiesen.length ||
liste.some((eintrag, stelle) => eintrag.vorlageId !== zugewiesen[stelle]?.vorlageId);
return (
<section className="rounded-karte border border-linie bg-flaeche p-5">
<h3 className="text-lg font-semibold">{GERAETE_TYP_TEXT[typ]}</h3>
{liste.length === 0 ? (
<p className="mt-2 text-sm text-nebentext">
Kein Schnellstart. Auf der Geräteseite erscheint dann nur „Vorlage zuweisen“.
</p>
) : (
<ol className="mt-3 flex flex-col gap-2">
{liste.map((eintrag, stelle) => (
<li
key={eintrag.vorlageId}
className="flex flex-wrap items-center gap-2 border-t border-linie py-2 first:border-t-0"
>
<span className="mono w-7 shrink-0 text-xs text-nebentext">
{String(stelle + 1).padStart(2, "0")}
</span>
<span className="min-w-0 flex-1 break-words">{eintrag.name}</span>
{eintrag.kundeName ? (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
nur {eintrag.kundeName}
</span>
) : null}
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => verschieben(stelle, -1)}
disabled={stelle === 0}
aria-label={`${eintrag.name} nach oben`}
className="flex size-11 items-center justify-center rounded-pille text-nebentext hover:bg-flaeche-hoch hover:text-text disabled:opacity-40 md:size-10"
>
<ArrowUp className="size-4" strokeWidth={1.75} aria-hidden="true" />
</button>
<button
type="button"
onClick={() => verschieben(stelle, 1)}
disabled={stelle === liste.length - 1}
aria-label={`${eintrag.name} nach unten`}
className="flex size-11 items-center justify-center rounded-pille text-nebentext hover:bg-flaeche-hoch hover:text-text disabled:opacity-40 md:size-10"
>
<ArrowDown className="size-4" strokeWidth={1.75} aria-hidden="true" />
</button>
<button
type="button"
onClick={() => setListe(liste.filter((andere) => andere.vorlageId !== eintrag.vorlageId))}
aria-label={`${eintrag.name} entfernen`}
className="flex size-11 items-center justify-center rounded-pille text-nebentext hover:bg-flaeche-hoch hover:text-fehler md:size-10"
>
<X className="size-4" strokeWidth={1.75} aria-hidden="true" />
</button>
</div>
</li>
))}
</ol>
)}
{offen.length > 0 ? (
<div className="mt-4 flex flex-wrap items-end gap-2">
<label className="flex min-w-0 flex-1 flex-col gap-1 text-sm">
<span className="text-nebentext">Vorlage hinzufügen</span>
<select
aria-label={`Vorlage für ${GERAETE_TYP_TEXT[typ]} hinzufügen`}
value=""
onChange={(ereignis) => {
const gewaehlt = offen.find((eintrag) => eintrag.vorlageId === ereignis.target.value);
if (gewaehlt) setListe([...liste, gewaehlt]);
}}
className="min-h-11 w-full rounded-pille border border-linie bg-flaeche-hoch px-4 text-sm md:min-h-10"
>
<option value="">auswählen …</option>
{offen.map((eintrag) => (
<option key={eintrag.vorlageId} value={eintrag.vorlageId}>
{eintrag.name}
{eintrag.kundeName ? ` (nur ${eintrag.kundeName})` : ""}
</option>
))}
</select>
</label>
<Plus className="mb-3 size-4 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
</div>
) : null}
<form action={aktion} className="mt-4 flex flex-wrap items-center gap-3">
<input type="hidden" name="geraeteTyp" value={typ} />
<input type="hidden" name="vorlageIds" value={liste.map((eintrag) => eintrag.vorlageId).join("\n")} />
<button
type="submit"
disabled={laeuft}
className="min-h-11 rounded-pille bg-akzent px-4 text-sm font-semibold text-grund md:min-h-10"
>
Speichern
</button>
{geaendert ? <span className="text-sm text-faellig">nicht gespeichert</span> : null}
</form>
{ergebnis.fehler ? (
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg && !geaendert ? (
<p role="status" className="mt-2 text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
</section>
);
}