Verwaltung: QR-Typen einzeln freigeben oder ausblenden

Neue Seite Verwaltung -> QR-Typen mit einem Schalter je Typ. Abgeschaltete
Typen verschwinden vollstaendig von der Startseite und lassen sich nicht
mehr neu anlegen.

- Neue Tabelle QrTypeSetting; fehlender Eintrag heisst freigegeben, damit
  eine frische Installation ohne Vorbelegung laeuft und spaetere Typen
  automatisch erscheinen.
- Registry-Feld 'active' heisst jetzt 'implemented' und beschreibt nur noch,
  ob der Typ ausprogrammiert ist. Ob er erscheint, entscheidet zusaetzlich
  die Freigabe aus der Datenbank (src/lib/qr/availability.ts).
- Bestehende dynamische Codes bleiben von einer Abschaltung unberuehrt: die
  Weiterleitung funktioniert weiter und Bearbeiten bleibt moeglich. Ein
  gedruckter Code darf durch einen Schalter nicht ungueltig werden.
- resolveQrRequest ist dadurch asynchron geworden; QrRequestError liegt jetzt
  in type-module.ts, sonst gaebe es einen Ringschluss mit availability.ts.
- Startseite meldet jetzt auch den Fall, dass nur noch Platzhalter
  freigegeben sind - vorher standen dort 14 tote Kacheln ohne Erklaerung.
- Kopfzeile: Schriftzug auf schmalen Geraeten ausgeblendet, sonst passte die
  dritte Navigationsschaltflaeche nicht mehr in die Zeile (7px Ueberlauf).
This commit is contained in:
Florian Wilde
2026-08-14 10:48:21 +02:00
parent 1e9e37f08d
commit c6da20f419
18 changed files with 684 additions and 148 deletions
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { useState } from "react";
import { Icon, type IconName } from "@/components/icons";
import { Badge, Card, Notice, cx } from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
import type { QrTypeId } from "@/lib/qr/type-module";
export type TypeSetting = {
id: QrTypeId;
title: string;
description: string;
icon: IconName;
implemented: boolean;
enabled: boolean;
};
function Toggle({
checked,
disabled,
label,
onChange,
}: {
checked: boolean;
disabled: boolean;
label: string;
onChange: (next: boolean) => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cx(
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-indigo-600" : "bg-slate-300",
)}
>
<span
className={cx(
"inline-block size-5 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-5.5" : "translate-x-0.5",
)}
/>
</button>
);
}
export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
const [types, setTypes] = useState(initial);
const [pending, setPending] = useState<QrTypeId | null>(null);
const [error, setError] = useState<string | null>(null);
const visibleCount = types.filter((entry) => entry.enabled).length;
const usableCount = types.filter(
(entry) => entry.enabled && entry.implemented,
).length;
const toggle = async (id: QrTypeId, enabled: boolean) => {
const before = types;
// Sofort umschalten und bei einem Fehler zurückdrehen — der Schalter
// soll sich nicht zäh anfühlen.
setTypes((current) =>
current.map((entry) => (entry.id === id ? { ...entry, enabled } : entry)),
);
setPending(id);
setError(null);
try {
await apiJson("/api/admin/types", {
method: "PATCH",
json: { type: id, enabled },
});
} catch (caught) {
setTypes(before);
setError(
caught instanceof ApiError
? caught.message
: "Die Änderung konnte nicht gespeichert werden.",
);
} finally {
setPending(null);
}
};
return (
<div className="space-y-4">
{error ? <Notice tone="error">{error}</Notice> : null}
{visibleCount === 0 ? (
<Notice tone="warning">
Zurzeit ist kein Typ freigegeben — die Startseite bleibt leer und es
lassen sich keine neuen Codes anlegen. Bereits gespeicherte
dynamische Codes leiten weiterhin ganz normal weiter.
</Notice>
) : usableCount === 0 ? (
<Notice tone="warning">
Es ist kein nutzbarer Typ freigegeben. Die freigegebenen Typen sind in
dieser Version noch nicht ausprogrammiert.
</Notice>
) : null}
<Card className="!p-0">
<ul className="divide-y divide-slate-200">
{types.map((entry) => (
<li
key={entry.id}
className="flex items-start gap-4 p-4 sm:items-center sm:px-5"
>
<span
className={cx(
"flex size-10 shrink-0 items-center justify-center rounded-xl",
entry.enabled && entry.implemented
? "bg-indigo-50 text-indigo-600"
: "bg-slate-100 text-slate-400",
)}
>
<Icon name={entry.icon} className="size-5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span
className={cx(
"font-medium",
entry.enabled ? "text-slate-900" : "text-slate-500",
)}
>
{entry.title}
</span>
{entry.implemented ? (
<Badge tone="success">verfügbar</Badge>
) : (
<Badge tone="soon">noch nicht umgesetzt</Badge>
)}
</div>
<p className="mt-0.5 text-sm text-slate-500">
{entry.enabled
? entry.implemented
? "Erscheint auf der Startseite und kann verwendet werden."
: "Erscheint auf der Startseite als „Bald verfügbar“."
: "Ausgeblendet — erscheint nicht auf der Startseite."}
</p>
</div>
<Toggle
checked={entry.enabled}
disabled={pending === entry.id}
label={`${entry.title} ${entry.enabled ? "ausblenden" : "freigeben"}`}
onChange={(next) => void toggle(entry.id, next)}
/>
</li>
))}
</ul>
</Card>
<p className="text-sm text-slate-500">
{usableCount} von {types.length} Typen nutzbar, {visibleCount} auf der
Startseite sichtbar.
</p>
</div>
);
}