Merge #5: Filtermöglichkeit der angelegten Codes
This commit was merged in pull request #13.
This commit is contained in:
@@ -618,13 +618,18 @@ Dazu kommen
|
|||||||
|
|
||||||
- ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ,
|
- ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ,
|
||||||
- eine **Sortierung** nach Datum, Name oder Scan-Anzahl,
|
- eine **Sortierung** nach Datum, Name oder Scan-Anzahl,
|
||||||
- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen.
|
- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen,
|
||||||
|
- ein **Filter nach Art**, sobald dynamische und statische Codes
|
||||||
|
nebeneinander liegen.
|
||||||
|
|
||||||
Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die
|
Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die
|
||||||
Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die
|
Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die
|
||||||
Kartenansicht.
|
Kartenansicht.
|
||||||
|
|
||||||
Statische Codes erscheinen hier nicht, weil sie nicht gespeichert werden.
|
Abgelegte **statische Codes** stehen mit in der Liste, tragen aber ein
|
||||||
|
Abzeichen „statisch“ statt einer Kurz-URL und zählen keine Scans. Steht der
|
||||||
|
Filter auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung nach
|
||||||
|
Scans mit ihr — bei einer Liste aus lauter Nullen sagt sie nichts.
|
||||||
|
|
||||||
Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur
|
Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur
|
||||||
Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare.
|
Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare.
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ export default async function MyCodesPage() {
|
|||||||
Meine Codes
|
Meine Codes
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-slate-600">
|
<p className="mt-2 text-slate-600">
|
||||||
Alle dynamischen QR-Codes mit ihrem aktuellen Ziel und der Anzahl der
|
Dynamische Codes mit ihrem aktuellen Ziel und der Anzahl der Scans,
|
||||||
Scans. Statische Codes erscheinen hier nicht — sie werden nicht
|
dazu abgelegte statische Codes. Über die Auswahl „Nach Art filtern“
|
||||||
gespeichert.
|
lässt sich die Liste auf eine der beiden Arten einschränken.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ function formatShortDate(value: string | null): string {
|
|||||||
|
|
||||||
type View = "list" | "cards";
|
type View = "list" | "cards";
|
||||||
type SortKey = "neueste" | "aelteste" | "name" | "scans";
|
type SortKey = "neueste" | "aelteste" | "name" | "scans";
|
||||||
|
/** Betriebsart, auf die die Liste eingeschränkt ist. */
|
||||||
|
type ModeFilter = "alle" | "dynamic" | "static";
|
||||||
|
|
||||||
const SORT_LABELS: Record<SortKey, string> = {
|
const SORT_LABELS: Record<SortKey, string> = {
|
||||||
neueste: "Neueste zuerst",
|
neueste: "Neueste zuerst",
|
||||||
@@ -51,6 +53,12 @@ const SORT_LABELS: Record<SortKey, string> = {
|
|||||||
scans: "Meiste Scans",
|
scans: "Meiste Scans",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MODE_LABELS: Record<ModeFilter, string> = {
|
||||||
|
alle: "Alle Arten",
|
||||||
|
dynamic: "Nur dynamische",
|
||||||
|
static: "Nur statische",
|
||||||
|
};
|
||||||
|
|
||||||
/** Merkt sich die gewählte Ansicht über Seitenwechsel hinweg. */
|
/** Merkt sich die gewählte Ansicht über Seitenwechsel hinweg. */
|
||||||
const VIEW_STORAGE_KEY = "qrgen.codes.view";
|
const VIEW_STORAGE_KEY = "qrgen.codes.view";
|
||||||
|
|
||||||
@@ -94,6 +102,7 @@ export function CodesList({
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [sort, setSort] = useState<SortKey>("neueste");
|
const [sort, setSort] = useState<SortKey>("neueste");
|
||||||
const [groupFilter, setGroupFilter] = useState("alle");
|
const [groupFilter, setGroupFilter] = useState("alle");
|
||||||
|
const [modeFilter, setModeFilter] = useState<ModeFilter>("alle");
|
||||||
|
|
||||||
// Erst nach dem Einhängen lesen — auf dem Server gibt es keinen
|
// Erst nach dem Einhängen lesen — auf dem Server gibt es keinen
|
||||||
// localStorage, und ein abweichender Startwert führte zu Hydrationsfehlern.
|
// localStorage, und ein abweichender Startwert führte zu Hydrationsfehlern.
|
||||||
@@ -107,6 +116,14 @@ export function CodesList({
|
|||||||
window.localStorage.setItem(VIEW_STORAGE_KEY, next);
|
window.localStorage.setItem(VIEW_STORAGE_KEY, next);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changeModeFilter = (next: ModeFilter) => {
|
||||||
|
setModeFilter(next);
|
||||||
|
// Statische Codes zählen keine Scans. Bleibt die Sortierung danach
|
||||||
|
// stehen, sortiert die Liste nach lauter Nullen — und die Auswahl bietet
|
||||||
|
// eine Reihenfolge an, die es dort gar nicht gibt.
|
||||||
|
if (next === "static" && sort === "scans") setSort("neueste");
|
||||||
|
};
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes");
|
const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes");
|
||||||
@@ -241,11 +258,25 @@ export function CodesList({
|
|||||||
);
|
);
|
||||||
}, [codes]);
|
}, [codes]);
|
||||||
|
|
||||||
|
// Welche Betriebsarten überhaupt vorkommen. Ein Filter, der nichts zu
|
||||||
|
// filtern hat, steht nur im Weg — deshalb erscheint er erst, wenn beide
|
||||||
|
// Arten in der Liste stehen.
|
||||||
|
const kinds = useMemo(() => {
|
||||||
|
let dynamisch = false;
|
||||||
|
let statisch = false;
|
||||||
|
for (const code of codes ?? []) {
|
||||||
|
if (code.mode === "static") statisch = true;
|
||||||
|
else dynamisch = true;
|
||||||
|
}
|
||||||
|
return { dynamisch, statisch, beide: dynamisch && statisch };
|
||||||
|
}, [codes]);
|
||||||
|
|
||||||
const visible = useMemo(() => {
|
const visible = useMemo(() => {
|
||||||
const needle = query.trim().toLowerCase();
|
const needle = query.trim().toLowerCase();
|
||||||
|
|
||||||
const filtered = (codes ?? []).filter((code) => {
|
const filtered = (codes ?? []).filter((code) => {
|
||||||
if (groupFilter !== "alle" && code.groupId !== groupFilter) return false;
|
if (groupFilter !== "alle" && code.groupId !== groupFilter) return false;
|
||||||
|
if (modeFilter !== "alle" && code.mode !== modeFilter) return false;
|
||||||
if (!needle) return true;
|
if (!needle) return true;
|
||||||
return [
|
return [
|
||||||
displayName(code),
|
displayName(code),
|
||||||
@@ -273,7 +304,7 @@ export function CodesList({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return sorted;
|
return sorted;
|
||||||
}, [codes, query, sort, groupFilter]);
|
}, [codes, query, sort, groupFilter, modeFilter]);
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- View */
|
/* ---------------------------------------------------------------- View */
|
||||||
|
|
||||||
@@ -287,6 +318,10 @@ export function CodesList({
|
|||||||
const svgHref = (code: QrCodeDto) =>
|
const svgHref = (code: QrCodeDto) =>
|
||||||
`/api/codes/${code.id}/qr?format=svg&download=1`;
|
`/api/codes/${code.id}/qr?format=svg&download=1`;
|
||||||
|
|
||||||
|
// Zeigt die Liste ausschließlich statische Codes, hat die Scan-Spalte
|
||||||
|
// nichts zu sagen — dann fällt sie weg, samt der Sortierung danach.
|
||||||
|
const nurStatische = modeFilter === "static" || (kinds.statisch && !kinds.dynamisch);
|
||||||
|
|
||||||
const toolbar = codes.length > 0 && (
|
const toolbar = codes.length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="relative min-w-0 flex-1 basis-56">
|
<div className="relative min-w-0 flex-1 basis-56">
|
||||||
@@ -320,17 +355,36 @@ export function CodesList({
|
|||||||
</Select>
|
</Select>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{kinds.beide ? (
|
||||||
|
<Select
|
||||||
|
aria-label="Nach Art filtern"
|
||||||
|
className="!w-auto"
|
||||||
|
value={modeFilter}
|
||||||
|
onChange={(event) =>
|
||||||
|
changeModeFilter(event.target.value as ModeFilter)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(Object.keys(MODE_LABELS) as ModeFilter[]).map((key) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{MODE_LABELS[key]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
aria-label="Sortierung"
|
aria-label="Sortierung"
|
||||||
className="!w-auto"
|
className="!w-auto"
|
||||||
value={sort}
|
value={sort}
|
||||||
onChange={(event) => setSort(event.target.value as SortKey)}
|
onChange={(event) => setSort(event.target.value as SortKey)}
|
||||||
>
|
>
|
||||||
{(Object.keys(SORT_LABELS) as SortKey[]).map((key) => (
|
{(Object.keys(SORT_LABELS) as SortKey[])
|
||||||
<option key={key} value={key}>
|
.filter((key) => key !== "scans" || !nurStatische)
|
||||||
{SORT_LABELS[key]}
|
.map((key) => (
|
||||||
</option>
|
<option key={key} value={key}>
|
||||||
))}
|
{SORT_LABELS[key]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -406,12 +460,13 @@ export function CodesList({
|
|||||||
{codes.length > 0 && visible.length === 0 ? (
|
{codes.length > 0 && visible.length === 0 ? (
|
||||||
<Card className="text-center">
|
<Card className="text-center">
|
||||||
<p className="text-sm text-slate-600">
|
<p className="text-sm text-slate-600">
|
||||||
Kein Code passt zur Suche.{" "}
|
Kein Code passt zu Suche und Filtern.{" "}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setGroupFilter("alle");
|
setGroupFilter("alle");
|
||||||
|
setModeFilter("alle");
|
||||||
}}
|
}}
|
||||||
className="font-medium text-accent-text underline"
|
className="font-medium text-accent-text underline"
|
||||||
>
|
>
|
||||||
@@ -446,9 +501,11 @@ export function CodesList({
|
|||||||
Gruppe
|
Gruppe
|
||||||
</th>
|
</th>
|
||||||
) : null}
|
) : null}
|
||||||
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
|
{nurStatische ? null : (
|
||||||
Scans
|
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
|
||||||
</th>
|
Scans
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="hidden w-24 px-3 py-2.5 whitespace-nowrap sm:table-cell"
|
className="hidden w-24 px-3 py-2.5 whitespace-nowrap sm:table-cell"
|
||||||
@@ -571,15 +628,20 @@ export function CodesList({
|
|||||||
</td>
|
</td>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
|
{nurStatische ? null : (
|
||||||
{code.mode === "static" ? (
|
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
|
||||||
<span className="font-normal text-slate-400" title="Statische Codes werden nicht gezählt">
|
{code.mode === "static" ? (
|
||||||
—
|
<span
|
||||||
</span>
|
className="font-normal text-slate-400"
|
||||||
) : (
|
title="Statische Codes werden nicht gezählt"
|
||||||
code.scans
|
>
|
||||||
)}
|
—
|
||||||
</td>
|
</span>
|
||||||
|
) : (
|
||||||
|
code.scans
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
|
||||||
<td
|
<td
|
||||||
className="hidden px-3 py-2.5 whitespace-nowrap text-slate-500 tabular-nums sm:table-cell"
|
className="hidden px-3 py-2.5 whitespace-nowrap text-slate-500 tabular-nums sm:table-cell"
|
||||||
|
|||||||
Reference in New Issue
Block a user