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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user