From 4c7524d89095bb952276618e2f8e9f50ea623477 Mon Sep 17 00:00:00 2001 From: Lars Lehmann Date: Thu, 10 Sep 2026 15:16:33 +0200 Subject: [PATCH 1/9] =?UTF-8?q?feat(ui):=20sortier-=20und=20filterbare=20D?= =?UTF-8?q?ataTable=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jede Dashboard-Liste hat ihre Tabelle bisher selbst gebaut, mit jeweils eigener (oder gar keiner) Sortierung. Diese gemeinsame Komponente auf Basis von TanStack Table v9 gibt allen dasselbe Verhalten: Sortierung je Spalte (Umschalt+Klick für mehrere), ein Trichter je Spalte mit Text-, Set-, Zahlen- und Datumsfilter, eine spaltenübergreifende Suche, ein Menü zum Ein- und Ausblenden von Spalten und einen Seitenumbruch. Der Set-Filter arbeitet mit Faceting: er bietet nur Werte an, die nach den übrigen Spaltenfiltern noch vorkommen, sodass keine Auswahl in eine leere Trefferliste führt. Die Menüs hängen in einem Portal, weil die Tabelle in einem `overflow-x-auto` scrollt und sie sonst abgeschnitten würden. Listen, die serverseitig geblättert werden, können Sortierung, Filter, Suche und Blättern über die `manual*`-Schalter selbst übernehmen — im Browser zu sortieren würde dort nur die gerade geladene Seite betreffen. --- package.json | 1 + pnpm-lock.yaml | 43 ++ src/app/_components/ui/README.md | 130 +++- src/app/_components/ui/data-table.tsx | 978 ++++++++++++++++++++++++++ src/app/_components/ui/index.ts | 9 + 5 files changed, 1157 insertions(+), 4 deletions(-) create mode 100644 src/app/_components/ui/data-table.tsx diff --git a/package.json b/package.json index e4c8da87..f1b1ebd1 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@t3-oss/env-nextjs": "^0.13.11", "@tailwindcss/typography": "^0.5.20", "@tanstack/react-query": "^5.102.8", + "@tanstack/react-table": "^9.2.4", "@tiptap/extension-image": "^3.31.3", "@tiptap/extension-link": "^3.31.3", "@tiptap/extension-placeholder": "^3.31.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c57355dd..69f0bdcc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@tanstack/react-query': specifier: ^5.102.8 version: 5.102.8(react@19.2.8) + '@tanstack/react-table': + specifier: ^9.2.4 + version: 9.2.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tiptap/extension-image': specifier: ^3.31.3 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) @@ -1650,6 +1653,25 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-store@0.11.1': + resolution: {integrity: sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/react-table@9.2.4': + resolution: {integrity: sha512-Rzp1Q4e0/nIgEjmISYR5HeEgLTNtrG+C7NFZf/AbCxPO5hg+zC3yNGwcoECigUk8SFzzvhXbd2rFdtP2ZiGrfA==} + engines: {node: '>=20'} + peerDependencies: + react: '>=18' + + '@tanstack/store@0.11.1': + resolution: {integrity: sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==} + + '@tanstack/table-core@9.2.4': + resolution: {integrity: sha512-GwdDyGGr6UXAtubF14yAwcXvdaogqfsQgIk89Suebjxob/Rjq2xvfmXsjDF1rQmKmhHsJm9TOY7myxv3i6geJw==} + engines: {node: '>=20'} + '@tiptap/core@3.31.3': resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==} peerDependencies: @@ -5748,6 +5770,27 @@ snapshots: '@tanstack/query-core': 5.102.8 react: 19.2.8 + '@tanstack/react-store@0.11.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/store': 0.11.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.2.8) + + '@tanstack/react-table@9.2.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/react-store': 0.11.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/table-core': 9.2.4 + react: 19.2.8 + transitivePeerDependencies: + - react-dom + + '@tanstack/store@0.11.1': {} + + '@tanstack/table-core@9.2.4': + dependencies: + '@tanstack/store': 0.11.1 + '@tiptap/core@3.31.3(@tiptap/pm@3.31.3)': dependencies: '@tiptap/pm': 3.31.3 diff --git a/src/app/_components/ui/README.md b/src/app/_components/ui/README.md index 2296409d..68736224 100644 --- a/src/app/_components/ui/README.md +++ b/src/app/_components/ui/README.md @@ -183,10 +183,124 @@ import { Alert, AlertTitle, AlertDescription } from "@/app/_components/ui"; ``` +### DataTable + +A sortable, filterable table built on TanStack Table v9. Every dashboard list +uses it, so sorting and filtering behave the same everywhere. + +Each column header carries a sort button (shift-click adds a second sort key) +and a funnel that opens a filter menu. `Spalten` in the toolbar shows and hides +columns; the search box filters across all columns at once. + +```tsx +import { + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; + +type Person = { id: string; name: string; city: string; joined: Date }; + +// Module scope: the helper and the feature set must not be re-created per render. +const column = createDataTableColumnHelper(); + +function People({ people }: { people: Person[] | undefined }) { + const columns = useMemo[]>( + () => + column.columns([ + column.accessor((person) => person.name, { + id: "name", + header: "Name", + meta: { alwaysVisible: true }, + cell: ({ row }) => {row.original.name}, + }), + column.accessor((person) => person.city, { + id: "city", + header: "Ort", + meta: { filterVariant: "set" }, + }), + column.accessor((person) => person.joined, { + id: "joined", + header: "Dabei seit", + sortFn: "datetime", + meta: { filterVariant: "date", align: "right" }, + cell: ({ getValue }) => getValue().toLocaleDateString("de-DE"), + }), + ]), + [], + ); + + return ( + person.id} + rowNoun={["Person", "Personen"]} + /> + ); +} +``` + +**Column `meta`** + +| Key | Effect | +| --------------- | ---------------------------------------------------------------- | +| `filterVariant` | `text` (default), `set` (checkbox list), `number`, `date` | +| `filterOptions` | Fixed `{value,label}` list for a `set` filter — needed for enums | +| `align` | `left` (default), `center`, `right` | +| `label` | Name in the column menu when `header` is not a plain string | +| `alwaysVisible` | Keeps the column out of the show/hide menu | +| `cellClassName` | Extra classes on every `` of the column | + +Use `enableColumnFilter: false` for columns that cannot be filtered sensibly, +and `enableSorting: false` for derived columns the server cannot order by. + +**Server-side lists.** A list that arrives one page at a time cannot sort or +filter in the browser without silently limiting itself to the current page. Pass +the state, its setter and the matching `manual*` flag, and turn them into query +input: + +```tsx + +``` + +Only the columns the server can order by should stay sortable, and only the +columns it can filter should keep their funnel — a control that silently does +nothing is worse than no control. + +**Wide tables on phones.** Pass `renderMobileRow` to replace the table with a +stacked card below `md`; the toolbar and pager stay. Without it the table +scrolls horizontally, which is fine up to about five columns. + ## Form Example ```tsx -import { Button, Input, Label, Textarea, Select, Card, CardHeader, CardTitle, CardContent } from "@/app/_components/ui"; +import { + Button, + Input, + Label, + Textarea, + Select, + Card, + CardHeader, + CardTitle, + CardContent, +} from "@/app/_components/ui"; function MyForm() { return ( @@ -197,12 +311,16 @@ function MyForm() {
- +
- +
@@ -222,7 +340,9 @@ function MyForm() {
- +
@@ -234,6 +354,7 @@ function MyForm() { ## Styling All components use Tailwind CSS and support dark mode automatically. They follow the design system defined in `src/styles/globals.css` with: + - Primary color: `#faa619` - Dark mode support - Consistent spacing and typography @@ -242,6 +363,7 @@ All components use Tailwind CSS and support dark mode automatically. They follow ## Accessibility All components are built with accessibility in mind: + - Proper ARIA attributes - Keyboard navigation support - Focus indicators diff --git a/src/app/_components/ui/data-table.tsx b/src/app/_components/ui/data-table.tsx new file mode 100644 index 00000000..0275e8cf --- /dev/null +++ b/src/app/_components/ui/data-table.tsx @@ -0,0 +1,978 @@ +"use client"; + +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { + columnFacetingFeature, + columnFilteringFeature, + columnVisibilityFeature, + createColumnHelper, + createFacetedRowModel, + createFacetedUniqueValues, + createFilteredRowModel, + createPaginatedRowModel, + createSortedRowModel, + filterFn_arrHas, + filterFn_inDateRange, + filterFn_inNumberRange, + filterFn_includesString, + globalFilteringFeature, + rowPaginationFeature, + rowSortingFeature, + sortFn_alphanumeric, + sortFn_basic, + sortFn_datetime, + sortFn_text, + tableFeatures, + useTable, + type Column, + type ColumnDef, + type ColumnFiltersState, + type ColumnVisibilityState, + type OnChangeFn, + type PaginationState, + type RowData, + type SortingState, + type Table, +} from "@tanstack/react-table"; +import { + ArrowDownIcon, + ArrowUpIcon, + ChevronsUpDownIcon, + Columns3Icon, + FilterIcon, + FilterXIcon, + SearchIcon, + XIcon, +} from "lucide-react"; + +/** + * Which filter the funnel in a column header opens. + * + * `set` is the AG-Grid-style checkbox list: it offers exactly the values still + * present after the *other* columns' filters, so the list never suggests a + * value that would produce an empty result. + */ +export type DataTableFilterVariant = "text" | "set" | "number" | "date"; + +export interface DataTableColumnMeta { + /** Horizontal alignment of header and cells. Defaults to `left`. */ + align?: "left" | "center" | "right"; + /** Filter UI offered in the header. Omit to fall back to `text`. */ + filterVariant?: DataTableFilterVariant; + /** + * Fixed options for a `set` filter, in the order they should be listed. + * Without this the distinct cell values are used — right for free text, wrong + * for enums that need a German label. + */ + filterOptions?: { value: string; label: string }[]; + /** Label for the column menu when `header` is not a plain string. */ + label?: string; + /** Extra classes for the ``. */ + headerClassName?: string; + /** Extra classes for every `` of this column. */ + cellClassName?: string; + /** Keeps the column out of the show/hide menu. */ + alwaysVisible?: boolean; +} + +/** + * The feature set every dashboard table shares. Registered once at module + * scope — v9 stitches features in statically, so re-creating it per render + * would rebuild every row model on every keystroke. + */ +export const dataTableFeatures = tableFeatures({ + columnFilteringFeature, + columnFacetingFeature, + columnVisibilityFeature, + globalFilteringFeature, + rowPaginationFeature, + rowSortingFeature, + filteredRowModel: createFilteredRowModel(), + facetedRowModel: createFacetedRowModel(), + facetedUniqueValues: createFacetedUniqueValues(), + sortedRowModel: createSortedRowModel(), + paginatedRowModel: createPaginatedRowModel(), + filterFns: { + includesString: filterFn_includesString, + arrHas: filterFn_arrHas, + inNumberRange: filterFn_inNumberRange, + inDateRange: filterFn_inDateRange, + }, + sortFns: { + alphanumeric: sortFn_alphanumeric, + basic: sortFn_basic, + datetime: sortFn_datetime, + text: sortFn_text, + }, + columnMeta: {} as DataTableColumnMeta, +}); + +export type DataTableFeatures = typeof dataTableFeatures; + +/** A column definition for {@link DataTable}. */ +export type DataTableColumn = ColumnDef< + DataTableFeatures, + TData, + // TanStack's own `columns()` helper widens here too: every column carries its + // own value type, and one array type cannot name them all. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any +>; + +/** + * Column helper bound to the shared feature set, so `meta` is typed as + * {@link DataTableColumnMeta} instead of the global fallback. + */ +export function createDataTableColumnHelper() { + return createColumnHelper(); +} + +/** Stable empty array — a fresh `?? []` fallback invalidates every row model. */ +const EMPTY_ROWS: never[] = []; + +const DEFAULT_PAGE_SIZE_OPTIONS = [25, 50, 100, 250]; + +function alignClass(align: DataTableColumnMeta["align"]): string { + if (align === "right") return "text-right"; + if (align === "center") return "text-center"; + return "text-left"; +} + +function columnLabel( + column: Column, +): string { + const meta = column.columnDef.meta; + if (meta?.label) return meta.label; + const header = column.columnDef.header; + return typeof header === "string" ? header : column.id; +} + +/* -------------------------------------------------------------------------- */ +/* Popover */ +/* -------------------------------------------------------------------------- */ + +/** + * Header menus live in a portal on `position: fixed`. The table scrolls inside + * `overflow-x-auto`, which would clip a menu anchored in a header cell, and on + * a narrow viewport the funnel of the last column sits at the very edge. + */ +function Popover({ + anchor, + onClose, + align = "start", + children, +}: { + anchor: HTMLElement | null; + onClose: () => void; + align?: "start" | "end"; + children: ReactNode; +}) { + const cardRef = useRef(null); + const [position, setPosition] = useState<{ + top: number; + left: number; + } | null>(null); + + useLayoutEffect(() => { + if (!anchor) return; + const place = () => { + const rect = anchor.getBoundingClientRect(); + const card = cardRef.current; + const width = card?.offsetWidth ?? 256; + const height = card?.offsetHeight ?? 256; + const margin = 8; + + let left = align === "end" ? rect.right - width : rect.left; + left = Math.min( + Math.max(margin, left), + Math.max(margin, window.innerWidth - width - margin), + ); + + let top = rect.bottom + 4; + if (top + height > window.innerHeight - margin) { + // Flip above the header when there is no room below it. + top = Math.max(margin, rect.top - height - 4); + } + setPosition({ top, left }); + }; + + place(); + window.addEventListener("resize", place); + window.addEventListener("scroll", place, true); + return () => { + window.removeEventListener("resize", place); + window.removeEventListener("scroll", place, true); + }; + }, [anchor, align]); + + useEffect(() => { + const onPointerDown = (event: MouseEvent | TouchEvent) => { + const target = event.target as Node; + if (cardRef.current?.contains(target)) return; + if (anchor?.contains(target)) return; + onClose(); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("touchstart", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("touchstart", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [anchor, onClose]); + + if (typeof document === "undefined") return null; + + return createPortal( +
+ {children} +
, + document.body, + ); +} + +/* -------------------------------------------------------------------------- */ +/* Column filters */ +/* -------------------------------------------------------------------------- */ + +const inputClass = + "dark:border-dark-border dark:bg-dark-background dark:text-dark-text focus:border-primary focus:ring-primary w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm focus:ring-1 focus:outline-none"; + +function SetFilter({ + column, +}: { + column: Column; +}) { + const [query, setQuery] = useState(""); + const selected = (column.getFilterValue() as string[] | undefined) ?? []; + + const options = useMemo(() => { + const configured = column.columnDef.meta?.filterOptions; + if (configured) return configured; + const facets = column.getFacetedUniqueValues(); + return [...facets.keys()] + .filter((value) => value !== null && value !== undefined && value !== "") + .map((value) => ({ value: String(value), label: String(value) })) + .sort((a, b) => a.label.localeCompare(b.label, "de")); + }, [column]); + + const visible = options.filter((option) => + option.label.toLowerCase().includes(query.toLowerCase()), + ); + + const setSelection = (values: string[]) => + column.setFilterValue(values.length > 0 ? values : undefined); + + const toggle = (value: string) => + setSelection( + selected.includes(value) + ? selected.filter((entry) => entry !== value) + : [...selected, value], + ); + + return ( +
+ {options.length > 8 && ( + setQuery(event.target.value)} + placeholder="Werte durchsuchen" + aria-label="Werte durchsuchen" + className={inputClass} + /> + )} +
+ + +
+
    + {visible.length === 0 ? ( +
  • + Keine Werte +
  • + ) : ( + visible.map((option) => ( +
  • + +
  • + )) + )} +
+
+ ); +} + +function RangeFilter({ + column, + type, +}: { + column: Column; + type: "number" | "date"; +}) { + const value = (column.getFilterValue() as [string, string] | undefined) ?? [ + "", + "", + ]; + + const update = (index: 0 | 1, next: string) => { + const range: [string, string] = + index === 0 ? [next, value[1] ?? ""] : [value[0] ?? "", next]; + column.setFilterValue( + range[0] === "" && range[1] === "" ? undefined : range, + ); + }; + + return ( +
+ + +
+ ); +} + +function TextFilter({ + column, +}: { + column: Column; +}) { + const value = (column.getFilterValue() as string | undefined) ?? ""; + return ( + + column.setFilterValue(event.target.value || undefined) + } + placeholder="Enthält …" + aria-label="Spalte filtern" + className={inputClass} + /> + ); +} + +function ColumnFilterMenu({ + column, +}: { + column: Column; +}) { + // The trigger element, not a boolean: the portalled menu positions itself + // against it, and a ref cannot be read during render. + const [anchor, setAnchor] = useState(null); + const open = anchor !== null; + const variant = column.columnDef.meta?.filterVariant ?? "text"; + const isFiltered = column.getIsFiltered(); + + return ( + <> + + {open && ( + setAnchor(null)} align="end"> +
+ + {columnLabel(column)} + + +
+ {variant === "set" ? ( + + ) : variant === "number" || variant === "date" ? ( + + ) : ( + + )} + {isFiltered && ( + + )} +
+ )} + + ); +} + +/* -------------------------------------------------------------------------- */ +/* Column visibility */ +/* -------------------------------------------------------------------------- */ + +function ColumnVisibilityMenu({ + table, +}: { + table: Table; +}) { + const [anchor, setAnchor] = useState(null); + const open = anchor !== null; + const hideable = table + .getAllLeafColumns() + .filter( + (column) => column.getCanHide() && !column.columnDef.meta?.alwaysVisible, + ); + + if (hideable.length === 0) return null; + + return ( + <> + + {open && ( + setAnchor(null)} align="end"> +

+ Spalten anzeigen +

+
    + {hideable.map((column) => ( +
  • + +
  • + ))} +
+
+ )} + + ); +} + +/* -------------------------------------------------------------------------- */ +/* DataTable */ +/* -------------------------------------------------------------------------- */ + +export interface DataTableProps { + data: TData[] | undefined; + columns: DataTableColumn[]; + /** Stable row identity — falls back to the array index. */ + getRowId?: (row: TData, index: number) => string; + isLoading?: boolean; + /** Shown instead of the body when there are no rows at all. */ + emptyState?: ReactNode; + /** Shown when rows exist but every one is filtered away. */ + noMatchState?: ReactNode; + /** Renders the search box above the table. Defaults to `true`. */ + searchable?: boolean; + searchPlaceholder?: string; + /** Extra controls rendered in the toolbar, right of the search box. */ + toolbar?: ReactNode; + /** Set to `false` to render every row at once. */ + paginated?: boolean; + pageSize?: number; + pageSizeOptions?: number[]; + initialSorting?: SortingState; + initialColumnVisibility?: ColumnVisibilityState; + /** Called for a click anywhere in the row. */ + onRowClick?: (row: TData) => void; + /** Extra classes for one row, e.g. to tint cancelled records. */ + rowClassName?: (row: TData) => string | undefined; + /** Caption for the row counter, singular and plural. */ + rowNoun?: [singular: string, plural: string]; + /** Hides the row counter and pager, for tables that carry their own. */ + hideFooter?: boolean; + /** + * Stacked card for one row, used below `md` instead of the table. + * + * A wide table only reaches a phone through horizontal scrolling, and columns + * scrolled past the edge are columns nobody reads. Tables with more than a + * handful of columns should hand over a card here; sorting, filters and the + * pager stay above and below it either way. + */ + renderMobileRow?: (row: TData) => ReactNode; + className?: string; + + /* + * Server-side mode. A table whose rows arrive one page at a time cannot sort + * or filter in the browser without silently limiting itself to the current + * page, so those slices are handed to the caller, who turns them into query + * input. Pass the state, its setter and the matching `manual*` flag together. + */ + sorting?: SortingState; + onSortingChange?: OnChangeFn; + manualSorting?: boolean; + columnFilters?: ColumnFiltersState; + onColumnFiltersChange?: OnChangeFn; + manualFiltering?: boolean; + /** Controlled search term, for a search that runs on the server. */ + search?: string; + onSearchChange?: (value: string) => void; + pagination?: PaginationState; + onPaginationChange?: OnChangeFn; + manualPagination?: boolean; + /** Total number of matching rows on the server, for the pager and counter. */ + rowCount?: number; +} + +export function DataTable({ + data, + columns, + getRowId, + isLoading = false, + emptyState, + noMatchState, + searchable = true, + searchPlaceholder = "Tabelle durchsuchen", + toolbar, + paginated = true, + pageSize = 25, + pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, + initialSorting, + initialColumnVisibility, + onRowClick, + rowClassName, + rowNoun = ["Eintrag", "Einträge"], + hideFooter = false, + renderMobileRow, + className, + sorting: sortingProp, + onSortingChange, + manualSorting, + columnFilters: columnFiltersProp, + onColumnFiltersChange, + manualFiltering, + search, + onSearchChange, + pagination: paginationProp, + onPaginationChange, + manualPagination, + rowCount, +}: DataTableProps) { + const rows = data ?? EMPTY_ROWS; + const serverSearch = search !== undefined; + + // The search box keeps its own value and pushes it on a timer: re-filtering + // a few thousand rows on every keystroke is what makes a table feel slow, + // and in server mode every keystroke would otherwise be a request. + const [searchInput, setSearchInput] = useState(search ?? ""); + const [globalFilter, setGlobalFilter] = useState(search ?? ""); + useEffect(() => { + const timer = setTimeout(() => { + setGlobalFilter(searchInput); + if (serverSearch) onSearchChange?.(searchInput); + }, 250); + return () => clearTimeout(timer); + // `onSearchChange` is a fresh closure on every render of most callers. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchInput, serverSearch]); + + const initialState = useMemo( + () => ({ + ...(initialSorting ? { sorting: initialSorting } : {}), + ...(initialColumnVisibility + ? { columnVisibility: initialColumnVisibility } + : {}), + pagination: { pageIndex: 0, pageSize }, + }), + // Seeds the starting state only; re-running it on a data change would + // throw the reader back to page one mid-scroll. + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const table = useTable({ + features: dataTableFeatures, + data: rows, + columns, + getRowId, + initialState, + globalFilterFn: "includesString", + // Dritter Klick hebt die Sortierung wieder auf. Serverseitig sortierte + // Tabellen müssen die leere Sortierung annehmen und auf ihre eigene + // Standardordnung zurückfallen — verwirft ihr `onSortingChange` sie + // stattdessen, hängt die Spalte auf "absteigend" fest und reagiert auf + // keinen weiteren Klick mehr. + enableSortingRemoval: true, + manualSorting, + manualFiltering, + manualPagination, + rowCount, + state: { + // In server mode the table must not also filter the fetched page, or the + // search term would be applied twice and hide matches the server found. + globalFilter: serverSearch ? "" : globalFilter, + ...(sortingProp ? { sorting: sortingProp } : {}), + ...(columnFiltersProp ? { columnFilters: columnFiltersProp } : {}), + ...(paginationProp ? { pagination: paginationProp } : {}), + }, + onGlobalFilterChange: setGlobalFilter, + ...(onSortingChange ? { onSortingChange } : {}), + ...(onColumnFiltersChange ? { onColumnFiltersChange } : {}), + ...(onPaginationChange ? { onPaginationChange } : {}), + }); + + const filteredCount = manualPagination + ? (rowCount ?? rows.length) + : table.getFilteredRowModel().rows.length; + const totalCount = manualPagination ? (rowCount ?? rows.length) : rows.length; + const pageRows = + paginated && !manualPagination + ? table.getPaginatedRowModel().rows + : table.getSortedRowModel().rows; + const sorting = table.state.sorting ?? []; + const activeFilters = table.state.columnFilters ?? []; + const canReset = activeFilters.length > 0 || searchInput !== ""; + + const resetAll = useCallback(() => { + table.resetColumnFilters(); + setSearchInput(""); + setGlobalFilter(""); + if (serverSearch) onSearchChange?.(""); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [table, serverSearch]); + + const columnCount = table.getVisibleLeafColumns().length; + const emptyContent = emptyState ?? ( + + Keine Daten vorhanden. + + ); + const noMatchContent = noMatchState ?? ( + + Keine Treffer für die aktuellen Filter. + + ); + const pageIndex = table.state.pagination?.pageIndex ?? 0; + const currentPageSize = table.state.pagination?.pageSize ?? pageSize; + + return ( +
+ {(searchable || toolbar) && ( +
+ {searchable && ( +
+ + setSearchInput(event.target.value)} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + className="dark:border-dark-border dark:bg-dark-background dark:text-dark-text focus:border-primary focus:ring-primary w-full rounded-lg border border-gray-300 py-2 pr-3 pl-9 text-sm focus:ring-1 focus:outline-none" + /> +
+ )} + {canReset && ( + + )} + + {toolbar} +
+ )} + +
+ {renderMobileRow && ( +
+ {isLoading ? ( +

+ Lade… +

+ ) : pageRows.length === 0 ? ( +
+ {rows.length === 0 ? emptyContent : noMatchContent} +
+ ) : ( +
    + {pageRows.map((row) => ( +
  • onRowClick(row.original) : undefined + } + className={`px-4 py-3 ${onRowClick ? "cursor-pointer" : ""} ${ + rowClassName?.(row.original) ?? "" + }`} + > + {renderMobileRow(row.original)} +
  • + ))} +
+ )} +
+ )} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta; + const canSort = header.column.getCanSort(); + const canFilter = header.column.getCanFilter(); + const sorted = canSort + ? header.column.getIsSorted() + : false; + return ( + + ); + })} + + ))} + + + {isLoading ? ( + + + + ) : pageRows.length === 0 ? ( + + + + ) : ( + pageRows.map((row) => ( + onRowClick(row.original) : undefined + } + className={`dark:hover:bg-dark-background-secondary hover:bg-gray-50 ${ + onRowClick ? "cursor-pointer" : "" + } ${rowClassName?.(row.original) ?? ""}`} + > + {row.getVisibleCells().map((cell) => { + const meta = cell.column.columnDef.meta; + return ( + + ); + })} + + )) + )} + +
+ {header.isPlaceholder ? null : ( +
+ {canSort ? ( + + ) : ( + + + + )} + {canFilter && ( + + )} +
+ )} +
+ Lade… +
+ {rows.length === 0 ? emptyContent : noMatchContent} +
+ +
+
+
+ + {!hideFooter && !isLoading && totalCount > 0 && ( +
+ + {filteredCount} {filteredCount === 1 ? rowNoun[0] : rowNoun[1]} + {canReset && filteredCount !== totalCount + ? ` von ${totalCount}` + : ""} + + {paginated && totalCount > Math.min(...pageSizeOptions) && ( +
+ + + + Seite {pageIndex + 1} von {Math.max(1, table.getPageCount())} + + +
+ )} +
+ )} +
+ ); +} diff --git a/src/app/_components/ui/index.ts b/src/app/_components/ui/index.ts index 5481d47f..7450b990 100644 --- a/src/app/_components/ui/index.ts +++ b/src/app/_components/ui/index.ts @@ -20,3 +20,12 @@ export { PasswordStrengthMeter, type PasswordStrengthMeterProps, } from "./password-strength-meter"; +export { + DataTable, + createDataTableColumnHelper, + dataTableFeatures, + type DataTableColumn, + type DataTableColumnMeta, + type DataTableFilterVariant, + type DataTableProps, +} from "./data-table"; From e78542a1e3732fb2ada8967bd9a424654ea814b5 Mon Sep 17 00:00:00 2001 From: Lars Lehmann Date: Thu, 10 Sep 2026 15:16:45 +0200 Subject: [PATCH 2/9] =?UTF-8?q?feat(api):=20Sortierung=20und=20Set-Filter?= =?UTF-8?q?=20f=C3=BCr=20die=20gepagten=20Dashboard-Listen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit-Log, Benutzer, Newsletter-Abonnenten und Anmeldungen werden serverseitig geblättert. Damit die Spaltenköpfe der neuen Tabellen dort über den ganzen Bestand sortieren und filtern statt nur über die gerade geladene Seite, nehmen die Abfragen jetzt `sortBy`/`sortOrder` entgegen und die bisherigen Einzelwert-Filter zusätzlich als Liste. Jede Sortierung außer der nach `createdAt` bekommt `createdAt` als zweites Kriterium: bei gleichen Werten ist die Reihenfolge sonst nicht festgelegt und dieselbe Zeile kann beim Blättern auf zwei Seiten auftauchen oder ganz ausfallen. Die Obergrenze für `limit` steigt mit, weil die Tabellen 50 bis 250 Zeilen je Seite anbieten. Die Standortliste holt ihren Bestand künftig am Stück; sie ist eine überschaubare Stammdatei, und nur so greifen Sortierung und Spaltenfilter über alle Zeilen. --- src/app/dashboard/page.tsx | 4 +- src/server/api/routers/audit.ts | 41 +++++++++++--- src/server/api/routers/registrations.ts | 68 ++++++++++++++++++----- src/server/api/routers/users.ts | 17 ++++-- src/server/api/routers/utils.ts | 72 +++++++++++++++++++++---- 5 files changed, 166 insertions(+), 36 deletions(-) diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index a334838b..3518ae40 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -108,7 +108,7 @@ export default function DashboardPage() { { enabled: ready && canManageRegistrations }, ); const { data: waitlisted } = api.registrations.getAllAdmin.useQuery( - { page: 1, limit: 1, registrationStatus: RegistrationStatus.WAITLIST }, + { page: 1, limit: 1, registrationStatus: [RegistrationStatus.WAITLIST] }, { enabled: ready && canManageRegistrations }, ); // Geschwisterrabatte gehören in dieselbe Freigabe-Warteschlange wie Kurse, @@ -119,7 +119,7 @@ export default function DashboardPage() { { page: 1, limit: 1, - siblingDiscountStatus: SiblingDiscountStatus.PENDING, + siblingDiscountStatus: [SiblingDiscountStatus.PENDING], }, { enabled: ready && (canManageRegistrations || canManageSiblingDiscount) }, ); diff --git a/src/server/api/routers/audit.ts b/src/server/api/routers/audit.ts index 1dc57e7c..406e2e29 100644 --- a/src/server/api/routers/audit.ts +++ b/src/server/api/routers/audit.ts @@ -9,17 +9,26 @@ export const auditRouter = createTRPCRouter({ .input( z.object({ page: z.number().min(1).default(1), - limit: z.number().min(1).max(100).default(50), - action: z.string().max(100).optional(), - entityType: z.string().max(100).optional(), + limit: z.number().min(1).max(250).default(50), + /* Set filters: an empty array means "no restriction". */ + action: z.array(z.string().max(100)).optional(), + entityType: z.array(z.string().max(100)).optional(), search: z.string().max(200).optional(), + sortBy: z + .enum(["createdAt", "actorEmail", "action", "entityType"]) + .default("createdAt"), + sortOrder: z.enum(["asc", "desc"]).default("desc"), }), ) .query(async ({ ctx, input }) => { const search = input.search?.trim(); + const actions = input.action?.length ? input.action : undefined; + const entityTypes = input.entityType?.length + ? input.entityType + : undefined; const where: Prisma.AuditLogWhereInput = { - ...(input.action && { action: input.action }), - ...(input.entityType && { entityType: input.entityType }), + ...(actions && { action: { in: actions } }), + ...(entityTypes && { entityType: { in: entityTypes } }), ...(search && { OR: [ { actorEmail: { contains: search, mode: "insensitive" } }, @@ -32,7 +41,12 @@ export const auditRouter = createTRPCRouter({ const [entries, total] = await Promise.all([ ctx.db.auditLog.findMany({ where, - orderBy: { createdAt: "desc" }, + // Zweites Kriterium, damit das Blättern bei gleichen Werten stabil + // bleibt und keine Zeile zweimal auf verschiedenen Seiten auftaucht. + orderBy: + input.sortBy === "createdAt" + ? [{ createdAt: input.sortOrder }] + : [{ [input.sortBy]: input.sortOrder }, { createdAt: "desc" }], skip: (input.page - 1) * input.limit, take: input.limit, }), @@ -46,7 +60,7 @@ export const auditRouter = createTRPCRouter({ }; }), - /** Distinct action values, for the filter dropdown. */ + /** Distinct action values, for the column filter. */ actions: permissionProcedure(PERMISSIONS.AUDIT_VIEW).query( async ({ ctx }) => { const rows = await ctx.db.auditLog.findMany({ @@ -58,4 +72,17 @@ export const auditRouter = createTRPCRouter({ return rows.map((r) => r.action); }, ), + + /** Distinct entity types, for the column filter. */ + entityTypes: permissionProcedure(PERMISSIONS.AUDIT_VIEW).query( + async ({ ctx }) => { + const rows = await ctx.db.auditLog.findMany({ + distinct: ["entityType"], + select: { entityType: true }, + orderBy: { entityType: "asc" }, + take: 200, + }); + return rows.map((r) => r.entityType); + }, + ), }); diff --git a/src/server/api/routers/registrations.ts b/src/server/api/routers/registrations.ts index 6c5de77b..a88fafa6 100644 --- a/src/server/api/routers/registrations.ts +++ b/src/server/api/routers/registrations.ts @@ -207,6 +207,29 @@ async function notifyCourseTeamOfNewRegistration( } } +/** Übersetzt die Sortierspalte der Anmeldungsliste in eine Prisma-Sortierung. */ +function registrationOrderBy( + sortBy: "createdAt" | "registrant" | "course" | "totalPrice" | "status", + sortOrder: "asc" | "desc", +): Prisma.CourseRegistrationOrderByWithRelationInput[] { + switch (sortBy) { + case "registrant": + return [ + { registrantLastName: sortOrder }, + { registrantFirstName: sortOrder }, + { createdAt: "desc" }, + ]; + case "course": + return [{ course: { title: sortOrder } }, { createdAt: "desc" }]; + case "totalPrice": + return [{ totalPrice: sortOrder }, { createdAt: "desc" }]; + case "status": + return [{ registrationStatus: sortOrder }, { createdAt: "desc" }]; + default: + return [{ createdAt: sortOrder }]; + } +} + export const registrationsRouter = createTRPCRouter({ create: rateLimitedPublicProcedure("registrations.create", { maxRequests: 20, @@ -1023,22 +1046,36 @@ export const registrationsRouter = createTRPCRouter({ .input( z.object({ page: z.number().min(1).default(1), - limit: z.number().min(1).max(100).default(25), + limit: z.number().min(1).max(250).default(25), search: z.string().max(200).optional(), - registrationStatus: z.nativeEnum(RegistrationStatus).optional(), + /* Set filters: an empty array means "no restriction". */ + registrationStatus: z + .array(z.nativeEnum(RegistrationStatus)) + .optional(), /** Nur Anmeldungen mit noch offener bzw. beglichener Rechnung. */ paid: z.boolean().optional(), - siblingDiscountStatus: z.nativeEnum(SiblingDiscountStatus).optional(), - courseId: z.string().optional(), + siblingDiscountStatus: z + .array(z.nativeEnum(SiblingDiscountStatus)) + .optional(), + courseId: z.array(z.string()).optional(), + sortBy: z + .enum(["createdAt", "registrant", "course", "totalPrice", "status"]) + .default("createdAt"), + sortOrder: z.enum(["asc", "desc"]).default("desc"), }), ) .query(async ({ ctx, input }) => { // Die Rabattberechtigung öffnet nur die Anmeldungen, über die sie // entscheidet: die mit einem Rabatt. NONE zählt ausdrücklich nicht dazu — // danach zu filtern wäre die ganze Tabelle minus einer Handvoll Zeilen. + const discountStatuses = input.siblingDiscountStatus?.length + ? input.siblingDiscountStatus + : undefined; const scopedToSiblingDiscount = - input.siblingDiscountStatus !== undefined && - input.siblingDiscountStatus !== SiblingDiscountStatus.NONE; + discountStatuses !== undefined && + discountStatuses.every( + (status) => status !== SiblingDiscountStatus.NONE, + ); if (!scopedToSiblingDiscount) { const canSeeEveryRegistration = await userHasPermission( @@ -1055,12 +1092,15 @@ export const registrationsRouter = createTRPCRouter({ } const search = input.search?.trim(); + const statuses = input.registrationStatus?.length + ? input.registrationStatus + : undefined; + const courseIds = input.courseId?.length ? input.courseId : undefined; + const where: Prisma.CourseRegistrationWhereInput = { - ...(input.registrationStatus && { - registrationStatus: input.registrationStatus, - }), - ...(input.siblingDiscountStatus && { - siblingDiscountStatus: input.siblingDiscountStatus, + ...(statuses && { registrationStatus: { in: statuses } }), + ...(discountStatuses && { + siblingDiscountStatus: { in: discountStatuses }, }), ...(input.paid === undefined ? {} @@ -1078,7 +1118,7 @@ export const registrationsRouter = createTRPCRouter({ some: { status: InvoiceStatus.PUBLISHED, paidAt: null }, }, }), - ...(input.courseId && { courseId: input.courseId }), + ...(courseIds && { courseId: { in: courseIds } }), ...(search && { OR: [ { registrantEmail: { contains: search, mode: "insensitive" } }, @@ -1129,7 +1169,9 @@ export const registrationsRouter = createTRPCRouter({ }, _count: { select: { participants: true } }, }, - orderBy: { createdAt: "desc" }, + // Zweites Kriterium, damit das Blättern bei gleichen Werten stabil + // bleibt und keine Zeile zweimal auf verschiedenen Seiten auftaucht. + orderBy: registrationOrderBy(input.sortBy, input.sortOrder), skip: (input.page - 1) * input.limit, take: input.limit, }), diff --git a/src/server/api/routers/users.ts b/src/server/api/routers/users.ts index 81f3614f..cda13fcf 100644 --- a/src/server/api/routers/users.ts +++ b/src/server/api/routers/users.ts @@ -288,11 +288,17 @@ export const usersRouter = createTRPCRouter({ .input( z.object({ page: z.number().min(1).default(1), - limit: z.number().min(1).max(100).default(20), + limit: z.number().min(1).max(250).default(20), // role filter removed - use permissions system instead search: z.string().optional(), sortBy: z - .enum(["displayName", "email", "createdAt"]) + .enum([ + "displayName", + "email", + "createdAt", + "lastLoginAt", + "emailVerified", + ]) .default("createdAt"), sortOrder: z.enum(["asc", "desc"]).default("desc"), }), @@ -335,7 +341,12 @@ export const usersRouter = createTRPCRouter({ }, skip: (input.page - 1) * input.limit, take: input.limit, - orderBy: { [input.sortBy]: input.sortOrder }, + // Zweites Kriterium, damit das Blättern bei gleichen Werten stabil + // bleibt und keine Zeile zweimal auf verschiedenen Seiten auftaucht. + orderBy: + input.sortBy === "createdAt" + ? [{ createdAt: input.sortOrder }] + : [{ [input.sortBy]: input.sortOrder }, { createdAt: "desc" }], }), ctx.db.user.count({ where }), ]); diff --git a/src/server/api/routers/utils.ts b/src/server/api/routers/utils.ts index 3fda589f..8b772f10 100644 --- a/src/server/api/routers/utils.ts +++ b/src/server/api/routers/utils.ts @@ -11,7 +11,7 @@ import { sendEmail } from "@/server/email/send-email"; import { generateNewsletterHtml } from "@/server/email/templates/newsletter-html"; import { maskEmail } from "@/lib/mask-email"; import { getBaseUrl } from "@/server/utils/get-base-url"; -import { ContentStatus } from "~/generated/prisma/client"; +import { ContentStatus, type Prisma } from "~/generated/prisma/client"; import { marked } from "marked"; import { geocodeAddress } from "@/server/utils/geocoding"; import { searchAddresses } from "@/server/utils/address-search"; @@ -33,7 +33,9 @@ export const locationsRouter = createTRPCRouter({ .input( z.object({ page: z.number().min(1).default(1), - limit: z.number().min(1).max(100).default(50), + // Bis 1000: die Standortverwaltung im Dashboard holt die ganze Liste + // auf einmal und sortiert und filtert sie im Browser. + limit: z.number().min(1).max(1000).default(50), city: z.string().optional(), zipCode: z.string().optional(), search: z.string().optional(), @@ -340,20 +342,60 @@ export const newsletterRouter = createTRPCRouter({ .input( z.object({ page: z.number().min(1).default(1), - limit: z.number().min(1).max(100).default(50), + limit: z.number().min(1).max(250).default(50), isActive: z.boolean().optional(), + /** + * Set filter over the three states the list shows. `confirmed` is what + * a newsletter actually reaches; `pending` signed up but never clicked + * the confirmation link. + */ + status: z + .array(z.enum(["confirmed", "pending", "inactive"])) + .optional(), search: z.string().optional(), + sortBy: z + .enum(["email", "name", "subscribedAt", "isActive"]) + .default("subscribedAt"), + sortOrder: z.enum(["asc", "desc"]).default("desc"), }), ) .query(async ({ ctx, input }) => { - const where = { + const statuses = input.status?.length ? input.status : undefined; + const statusClauses: Prisma.NewsletterSubscriberWhereInput[] = ( + statuses ?? [] + ).map((status) => + status === "confirmed" + ? { isActive: true, confirmedAt: { not: null } } + : status === "pending" + ? { isActive: true, confirmedAt: null } + : { isActive: false }, + ); + + const where: Prisma.NewsletterSubscriberWhereInput = { ...(input.isActive !== undefined && { isActive: input.isActive }), - ...(input.search && { - OR: [ - { email: { contains: input.search, mode: "insensitive" as const } }, - { name: { contains: input.search, mode: "insensitive" as const } }, - ], - }), + AND: [ + ...(statusClauses.length ? [{ OR: statusClauses }] : []), + ...(input.search + ? [ + { + OR: [ + { + email: { + contains: input.search, + mode: "insensitive" as const, + }, + }, + { + name: { + contains: input.search, + mode: "insensitive" as const, + }, + }, + ], + }, + ] + : []), + ], }; const [subscribers, total] = await Promise.all([ @@ -361,7 +403,15 @@ export const newsletterRouter = createTRPCRouter({ where, skip: (input.page - 1) * input.limit, take: input.limit, - orderBy: { subscribedAt: "desc" }, + // Zweites Kriterium, damit das Blättern bei gleichen Werten stabil + // bleibt und keine Zeile zweimal auf verschiedenen Seiten auftaucht. + orderBy: + input.sortBy === "subscribedAt" + ? [{ subscribedAt: input.sortOrder }] + : [ + { [input.sortBy]: input.sortOrder }, + { subscribedAt: "desc" as const }, + ], }), ctx.db.newsletterSubscriber.count({ where }), ]); From 6e8b80aa60a2d2a1fca31fb7651c867ba3b4db49 Mon Sep 17 00:00:00 2001 From: Lars Lehmann Date: Thu, 10 Sep 2026 15:17:06 +0200 Subject: [PATCH 3/9] refactor(dashboard): Dashboard-Tabellen auf die DataTable umstellen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alle Listen des Dashboards nutzen jetzt dieselbe Tabelle. Damit lässt sich überall nach jeder Spalte sortieren und filtern, statt wie bisher je nach Seite mit einer Sortier-Auswahlliste, einem Suchfeld oder gar nichts vorliebzunehmen. Wo der Bestand überschaubar ist, holt die Seite ihn am Stück und sortiert und filtert im Browser. Audit-Log, Benutzer, Anmeldungen und Newsletter-Abonnenten bleiben serverseitig geblättert und reichen Sortierung, Filter und Suche an die Abfrage weiter; dort tragen nur die Spalten einen Schalter, die der Server auch bedienen kann — ein Bedienelement, das nichts tut, ist schlimmer als keines. Die Listen mit gespeicherter Reihenfolge (Team, Vorstand, Posaunenrat, Posaunenwarte, Förderverein) bekommen eine eigene Spalte dafür und sortieren zunächst danach. Die Hoch-/Runter-Pfeile verschieben weiterhin die gespeicherte Position, egal wonach die Ansicht gerade sortiert ist. Das Audit-Log behält seine gestapelte Kartenansicht auf schmalen Fenstern: fünf Spalten mit einer Detailspalte erreichen ein Telefon sonst nur über waagerechtes Scrollen. --- .../dashboard/dashboard-users-list.tsx | 533 +++++++-------- src/app/dashboard/audit/page.tsx | 399 ++++++----- src/app/dashboard/auswahlchoere/page.tsx | 408 +++++------- src/app/dashboard/bezirke/page.tsx | 351 +++++----- src/app/dashboard/blaeserhefte/page.tsx | 333 +++++----- src/app/dashboard/downloads/page.tsx | 397 +++++------ src/app/dashboard/ensembles/page.tsx | 599 +++++++---------- src/app/dashboard/foerderverein/page.tsx | 381 ++++++----- src/app/dashboard/history-timeline/page.tsx | 440 +++++------- src/app/dashboard/locations/page.tsx | 473 +++++-------- .../dashboard/newsletter/subscribers/page.tsx | 345 +++++----- src/app/dashboard/permissions/page.tsx | 240 +++---- src/app/dashboard/posaunenrat/page.tsx | 350 +++++----- src/app/dashboard/posaunenwarte/page.tsx | 391 ++++++----- src/app/dashboard/registrations/page.tsx | 624 +++++++++--------- src/app/dashboard/stats/page.tsx | 180 ++--- src/app/dashboard/team/page.tsx | 394 +++++------ src/app/dashboard/vorstand/page.tsx | 380 ++++++----- 18 files changed, 3410 insertions(+), 3808 deletions(-) diff --git a/src/app/_components/dashboard/dashboard-users-list.tsx b/src/app/_components/dashboard/dashboard-users-list.tsx index b69fe5b4..f996f50e 100644 --- a/src/app/_components/dashboard/dashboard-users-list.tsx +++ b/src/app/_components/dashboard/dashboard-users-list.tsx @@ -1,19 +1,16 @@ "use client"; -import { useState } from "react"; -import { api } from "@/trpc/react"; +import { useMemo, useState } from "react"; +import { api, type RouterOutputs } from "@/trpc/react"; import Link from "next/link"; import { useSession } from "@/lib/auth"; +import { CheckCircle2, XCircle, Users } from "lucide-react"; import { - ArrowUpDown, - ArrowUp, - ArrowDown, - CheckCircle2, - XCircle, - Filter, - Users, - Search, -} from "lucide-react"; + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; +import type { PaginationState, SortingState } from "@tanstack/react-table"; import { ScrollableModal, ScrollableModalCard, @@ -21,44 +18,98 @@ import { ScrollableModalFooter, } from "@/app/_components/ui/scrollable-modal"; -type SortField = "displayName" | "email" | "createdAt"; +type ListedUser = RouterOutputs["users"]["list"]["users"][number]; -function SortIcon({ - field, - sortBy, - sortOrder, -}: { - field: SortField; - sortBy: SortField; - sortOrder: "asc" | "desc"; -}) { - if (sortBy !== field) { - return ; +/** The columns the server can sort by. */ +const SORTABLE_COLUMNS = { + displayName: "displayName", + emailVerified: "emailVerified", + createdAt: "createdAt", + lastLoginAt: "lastLoginAt", +} as const; + +type SortableColumn = keyof typeof SORTABLE_COLUMNS; + +const column = createDataTableColumnHelper(); + +/** Die Gremien, in denen die Person sitzt — mit ihrer Farbgebung. */ +function membershipBadges( + user: ListedUser, +): { label: string; className: string }[] { + const badges: { label: string; className: string }[] = []; + if (user.posaunenwart?.roleType === "LPW") { + badges.push({ + label: "LPW", + className: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400", + }); } - return sortOrder === "asc" ? ( - - ) : ( - - ); + if (user.posaunenwart?.roleType === "RPW") { + badges.push({ + label: "RPW", + className: + "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400", + }); + } + if (user.teamMember) { + badges.push({ + label: "Team", + className: + "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400", + }); + } + if (user.vorstandMember) { + badges.push({ + label: "Vorstand", + className: + "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400", + }); + } + if (user.posaunenratMember) { + badges.push({ + label: "Posaunenrat", + className: + "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", + }); + } + if (user.foerdervereinMember) { + badges.push({ + label: "Förderverein", + className: + "bg-foerderverein-light/40 text-foerderverein-dark dark:bg-foerderverein/20 dark:text-foerderverein-light", + }); + } + return badges; +} + +/** Dieselben Gremien als Text — die Suchgrundlage der Spalte. */ +function membershipLabels(user: ListedUser): string { + return membershipBadges(user) + .map((badge) => badge.label) + .join(", "); } export default function DashboardUsersList() { const { data: session } = useSession(); - const [sortBy, setSortBy] = useState("createdAt"); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); + // Die Benutzerliste wächst unbegrenzt und wird deshalb serverseitig + // geblättert; Sortierung und Suche sind darum Abfrageparameter — sonst würden + // sie nur die gerade geladene Seite betreffen. + const [sorting, setSorting] = useState([ + { id: "createdAt", desc: true }, + ]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 20, + }); const [search, setSearch] = useState(""); - const [page, setPage] = useState(1); - const [filtersOpen, setFiltersOpen] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(null); - const limit = 20; const utils = api.useUtils(); const { data, isLoading, error } = api.users.list.useQuery({ - page, - limit, + page: pagination.pageIndex + 1, + limit: pagination.pageSize, search: search || undefined, - sortBy, - sortOrder, + sortBy: SORTABLE_COLUMNS[(sorting[0]?.id ?? "createdAt") as SortableColumn], + sortOrder: sorting[0]?.desc === false ? "asc" : "desc", }); const { data: stats } = api.users.getStatistics.useQuery(); @@ -74,15 +125,131 @@ export default function DashboardUsersList() { }, }); - const handleSort = (field: SortField) => { - if (sortBy === field) { - setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); - } else { - setSortBy(field); - setSortOrder("asc"); - } - setPage(1); - }; + const columns = useMemo[]>( + () => + column.columns([ + column.accessor((user) => user.displayName ?? "Unbenannt", { + id: "displayName", + header: "Benutzer", + enableColumnFilter: false, + meta: { alwaysVisible: true }, + cell: ({ row }) => { + const user = row.original; + return ( +
+
+ {user.profileImage?.url ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ {(user.displayName ?? user.email)?.[0]?.toUpperCase()} +
+ )} +
+
+ + {user.displayName ?? "Unbenannt"} + +

+ {user.email} +

+
+
+ ); + }, + }), + column.accessor(membershipLabels, { + id: "memberships", + header: "Mitgliedschaften", + enableSorting: false, + enableColumnFilter: false, + cell: ({ row }) => { + const badges = membershipBadges(row.original); + if (badges.length === 0) { + return ( + + – + + ); + } + return ( +
+ {badges.map((badge) => ( + + {badge.label} + + ))} +
+ ); + }, + }), + column.accessor((user) => user.emailVerified, { + id: "emailVerified", + header: "E-Mail bestätigt", + enableColumnFilter: false, + meta: { align: "center", label: "E-Mail bestätigt" }, + cell: ({ getValue }) => + getValue() ? ( + + ) : ( + + ), + }), + column.accessor((user) => user.createdAt, { + id: "createdAt", + header: "Erstellt", + enableColumnFilter: false, + meta: { cellClassName: "whitespace-nowrap" }, + cell: ({ getValue }) => + new Date(getValue()).toLocaleDateString("de-DE"), + }), + column.accessor((user) => user.lastLoginAt, { + id: "lastLoginAt", + header: "Letzter Login", + enableColumnFilter: false, + meta: { cellClassName: "whitespace-nowrap" }, + cell: ({ getValue }) => { + const value = getValue(); + return value ? new Date(value).toLocaleDateString("de-DE") : "–"; + }, + }), + column.display({ + id: "actions", + header: "Aktionen", + meta: { align: "right", label: "Aktionen" }, + cell: ({ row }) => ( +
+ + Bearbeiten + + {session?.user.id !== row.original.id && ( + + )} +
+ ), + }), + ]), + [session?.user], + ); if (error) { return ( @@ -122,246 +289,40 @@ export default function DashboardUsersList() { )} - {/* Search & Filters */} -
- {/* Search */} -
- { - setSearch(e.target.value); - setPage(1); - }} - placeholder="Suche nach Name, E-Mail..." - className="focus:border-primary focus:ring-primary dark:border-dark-border dark:bg-dark-surface dark:text-dark-text block w-full rounded-lg border border-gray-300 bg-white py-2 pr-4 pl-10 text-gray-900 focus:ring-1 focus:outline-none" - /> - -
- - {/* Filter Toggle (Mobile) */} - -
- - {/* Users Table */} - {isLoading ? ( -
-
-
- ) : data?.users.length === 0 ? ( -
- -

- Keine Benutzer gefunden -

-

- {search - ? "Versuche eine andere Suche." - : "Es gibt noch keine Benutzer."} -

-
- ) : ( -
-
- - - - - - - - - - - - - {data?.users.map((user) => ( - - - - - - - - - ))} - -
handleSort("displayName")} - > -
- Benutzer - -
-
- Mitgliedschaften - - E-Mail bestätigt - handleSort("createdAt")} - > -
- Erstellt - -
-
- Letzter Login - - Aktionen -
-
-
- {user.profileImage?.url ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( -
- {(user.displayName ?? - user.email)?.[0]?.toUpperCase()} -
- )} -
-
- - {user.displayName ?? "Unbenannt"} - -

- {user.email} -

-
-
-
-
- {user.posaunenwart?.roleType === "LPW" && ( - - LPW - - )} - {user.posaunenwart?.roleType === "RPW" && ( - - RPW - - )} - {user.teamMember && ( - - Team - - )} - {user.vorstandMember && ( - - Vorstand - - )} - {user.posaunenratMember && ( - - Posaunenrat - - )} - {user.foerdervereinMember && ( - - Förderverein - - )} - {!user.teamMember && - !user.vorstandMember && - !user.posaunenratMember && - !user.foerdervereinMember && - !user.posaunenwart && ( - - – - - )} -
-
- {user.emailVerified ? ( - - ) : ( - - )} - - - {new Date(user.createdAt).toLocaleDateString("de-DE")} - - - - {user.lastLoginAt - ? new Date(user.lastLoginAt).toLocaleDateString( - "de-DE", - ) - : "–"} - - -
- - Bearbeiten - - {session?.user.id !== user.id && ( - - )} -
-
-
- - {/* Pagination */} - {data && data.pages > 1 && ( -
-

- Seite {data.page} von {data.pages} ({data.total} Benutzer) -

-
- - -
-
- )} -
- )} + user.id} + isLoading={isLoading} + rowNoun={["Benutzer", "Benutzer"]} + searchPlaceholder="Suche nach Name, E-Mail…" + pageSizeOptions={[20, 50, 100, 250]} + initialColumnVisibility={{ lastLoginAt: false }} + emptyState={ + <> + +

+ Keine Benutzer gefunden +

+

+ Es gibt noch keine Benutzer. +

+ + } + sorting={sorting} + onSortingChange={setSorting} + manualSorting + search={search} + onSearchChange={(value) => { + setSearch(value); + setPagination((current) => ({ ...current, pageIndex: 0 })); + }} + pagination={pagination} + onPaginationChange={setPagination} + manualPagination + manualFiltering + rowCount={data?.total ?? 0} + /> {/* Delete Confirmation Modal */} {showDeleteModal && ( diff --git a/src/app/dashboard/audit/page.tsx b/src/app/dashboard/audit/page.tsx index 88341534..eab0f5db 100644 --- a/src/app/dashboard/audit/page.tsx +++ b/src/app/dashboard/audit/page.tsx @@ -1,11 +1,35 @@ "use client"; -import { useState } from "react"; -import { api } from "@/trpc/react"; +import { useMemo, useState } from "react"; +import { api, type RouterOutputs } from "@/trpc/react"; import { DashboardPage } from "@/app/_components/dashboard"; +import { + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; import { usePermissions } from "@/lib/use-permissions"; import { PERMISSIONS } from "@/lib/permissions"; -import { SearchIcon, ShieldIcon } from "lucide-react"; +import type { + ColumnFiltersState, + PaginationState, + SortingState, +} from "@tanstack/react-table"; +import { ShieldIcon } from "lucide-react"; + +type AuditEntry = RouterOutputs["audit"]["list"]["entries"][number]; + +/** The columns the server can sort by. */ +const SORTABLE_COLUMNS = { + createdAt: "createdAt", + actor: "actorEmail", + action: "action", + entity: "entityType", +} as const; + +type SortableColumn = keyof typeof SORTABLE_COLUMNS; + +const column = createDataTableColumnHelper(); function formatDateTime(date: Date | string) { return new Intl.DateTimeFormat("de-DE", { @@ -18,34 +42,128 @@ function formatDateTime(date: Date | string) { }).format(new Date(date)); } +/** Reads one set filter out of the table's filter state. */ +function setFilter(filters: ColumnFiltersState, id: string): string[] { + const value = filters.find((filter) => filter.id === id)?.value; + return Array.isArray(value) ? (value as string[]) : []; +} + export default function AuditLogPage() { const { hasPermission, isLoading: permissionsLoading } = usePermissions(); + const canView = hasPermission(PERMISSIONS.AUDIT_VIEW); - const [page, setPage] = useState(1); + // Das Audit-Log wächst unbegrenzt und wird deshalb serverseitig geblättert; + // Sortierung, Spaltenfilter und Suche sind darum Abfrageparameter — sonst + // würden sie nur die gerade geladenen 50 Zeilen betreffen. + const [sorting, setSorting] = useState([ + { id: "createdAt", desc: true }, + ]); + const [columnFilters, setColumnFilters] = useState([]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 50, + }); const [search, setSearch] = useState(""); - const [searchInput, setSearchInput] = useState(""); - const [action, setAction] = useState(""); - const canView = hasPermission(PERMISSIONS.AUDIT_VIEW); + const sortBy = (sorting[0]?.id ?? "createdAt") as SortableColumn; + const actionFilter = setFilter(columnFilters, "action"); + const entityFilter = setFilter(columnFilters, "entity"); const { data, isLoading } = api.audit.list.useQuery( { - page, - limit: 50, + page: pagination.pageIndex + 1, + limit: pagination.pageSize, search: search || undefined, - action: action || undefined, + action: actionFilter.length ? actionFilter : undefined, + entityType: entityFilter.length ? entityFilter : undefined, + sortBy: SORTABLE_COLUMNS[sortBy] ?? "createdAt", + sortOrder: sorting[0]?.desc === false ? "asc" : "desc", }, { enabled: canView }, ); + const { data: actions } = api.audit.actions.useQuery(undefined, { enabled: canView, }); + const { data: entityTypes } = api.audit.entityTypes.useQuery(undefined, { + enabled: canView, + }); - const applySearch = (e: React.FormEvent) => { - e.preventDefault(); - setSearch(searchInput.trim()); - setPage(1); - }; + const columns = useMemo[]>( + () => + column.columns([ + column.accessor((entry) => entry.createdAt, { + id: "createdAt", + header: "Zeitpunkt", + enableColumnFilter: false, + meta: { + alwaysVisible: true, + cellClassName: "whitespace-nowrap tabular-nums", + }, + cell: ({ getValue }) => formatDateTime(getValue()), + }), + column.accessor( + (entry) => entry.actorEmail ?? entry.actorId ?? "System", + { + id: "actor", + header: "Akteur", + enableColumnFilter: false, + meta: { cellClassName: "break-all" }, + }, + ), + column.accessor((entry) => entry.action, { + id: "action", + header: "Aktion", + meta: { + filterVariant: "set", + filterOptions: (actions ?? []).map((value) => ({ + value, + label: value, + })), + }, + cell: ({ getValue }) => ( + + {getValue()} + + ), + }), + column.accessor((entry) => entry.entityType, { + id: "entity", + header: "Objekt", + meta: { + filterVariant: "set", + filterOptions: (entityTypes ?? []).map((value) => ({ + value, + label: value, + })), + }, + cell: ({ row }) => ( + <> + {row.original.entityType} + {row.original.entityId ? ( + + {row.original.entityId} + + ) : null} + + ), + }), + column.display({ + id: "details", + header: "Details", + meta: { label: "Details", cellClassName: "max-w-md" }, + cell: ({ row }) => + row.original.details ? ( +
+                {JSON.stringify(row.original.details, null, 1)}
+              
+ ) : ( + "–" + ), + }), + ]), + [actions, entityTypes], + ); if (!permissionsLoading && !canView) { return ( @@ -62,197 +180,72 @@ export default function AuditLogPage() { title="Audit-Log" description="Sicherheitsrelevante Aktionen: wer hat wann was geändert" > -
-
-
- -
- setSearchInput(e.target.value)} - placeholder="Akteur-E-Mail, Aktion oder Objekt-ID…" - className="dark:bg-dark-background dark:border-dark-border dark:text-dark-text w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" - /> - -
-
- -
- - -
-
-
- -
- {isLoading ? ( -
-
-
- ) : !data || data.entries.length === 0 ? ( -
+ entry.id} + isLoading={isLoading} + rowNoun={["Eintrag", "Einträge"]} + searchPlaceholder="Akteur-E-Mail, Aktion oder Objekt-ID…" + pageSizeOptions={[50, 100, 250]} + emptyState={ + -

Keine Einträge gefunden.

-
- ) : ( - <> - {/* Below md the five columns (the Details cell alone is max-w-md) - only reach the reader by horizontal scrolling, so each entry - becomes a stacked card instead. */} -
    - {data.entries.map((entry) => ( -
  • -
    - - {entry.action} - - -
    -

    - {entry.actorEmail ?? entry.actorId ?? "System"} -

    -

    - {entry.entityType} - {entry.entityId ? ( - - {entry.entityId} - - ) : null} -

    - {entry.details ? ( -
    - - Details - -
    -                        {JSON.stringify(entry.details, null, 1)}
    -                      
    -
    - ) : null} -
  • - ))} -
- -
- - - - {["Zeitpunkt", "Akteur", "Aktion", "Objekt", "Details"].map( - (header) => ( - - ), - )} - - - - {data.entries.map((entry) => ( - - - - - - - - ))} - -
- {header} -
- {formatDateTime(entry.createdAt)} - - {entry.actorEmail ?? entry.actorId ?? "System"} - - - {entry.action} - - - {entry.entityType} - {entry.entityId ? ( - - {entry.entityId} - - ) : null} - - {entry.details ? ( -
-                            {JSON.stringify(entry.details, null, 1)}
-                          
- ) : ( - "–" - )} -
+ Keine Einträge gefunden. + + } + renderMobileRow={(entry) => ( +
+
+ + {entry.action} + +
- -
-

- {data.total} {data.total === 1 ? "Eintrag" : "Einträge"} -

- {data.pages > 1 && ( -
- - - Seite {page} von {data.pages} - - -
- )} -
- +

+ {entry.actorEmail ?? entry.actorId ?? "System"} +

+

+ {entry.entityType} + {entry.entityId ? ( + + {entry.entityId} + + ) : null} +

+ {entry.details ? ( +
+ + Details + +
+                  {JSON.stringify(entry.details, null, 1)}
+                
+
+ ) : null} +
)} -
+ sorting={sorting} + onSortingChange={setSorting} + manualSorting + columnFilters={columnFilters} + onColumnFiltersChange={(updater) => { + setColumnFilters(updater); + setPagination((current) => ({ ...current, pageIndex: 0 })); + }} + manualFiltering + search={search} + onSearchChange={(value) => { + setSearch(value); + setPagination((current) => ({ ...current, pageIndex: 0 })); + }} + pagination={pagination} + onPaginationChange={setPagination} + manualPagination + rowCount={data?.total ?? 0} + /> ); } diff --git a/src/app/dashboard/auswahlchoere/page.tsx b/src/app/dashboard/auswahlchoere/page.tsx index 4fa5f7b0..463660c9 100644 --- a/src/app/dashboard/auswahlchoere/page.tsx +++ b/src/app/dashboard/auswahlchoere/page.tsx @@ -1,37 +1,33 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useSession } from "@/lib/auth"; import { useToast } from "@/app/_components/ui/toast"; import { useRouter } from "next/navigation"; import { useEffect, useRef } from "react"; -import { api } from "@/trpc/react"; +import { api, type RouterOutputs } from "@/trpc/react"; import { usePermissions } from "@/lib/use-permissions"; import { PERMISSIONS } from "@/lib/permissions"; import Link from "next/link"; import Image from "next/image"; import { DashboardPage } from "@/app/_components/dashboard"; import { - Plus, - Search, - Music, - Edit, - Trash2, - Eye, - EyeOff, - ChevronLeft, - ChevronRight, -} from "lucide-react"; + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; +import { Plus, Music, Edit, Trash2, Eye, EyeOff } from "lucide-react"; + +type Auswahlchor = + RouterOutputs["auswahlchoere"]["getAll"]["auswahlchoere"][number]; + +const column = createDataTableColumnHelper(); export default function DashboardAuswahlchoerePage() { const router = useRouter(); const { data: session, isPending } = useSession(); const hasRedirected = useRef(false); const [deletingId, setDeletingId] = useState(null); - const [search, setSearch] = useState(""); - const [page, setPage] = useState(1); - const limit = 20; - const toast = useToast(); const { data: profile, isLoading: profileLoading } = @@ -44,15 +40,13 @@ export default function DashboardAuswahlchoerePage() { PERMISSIONS.ORGANIZATION_MANAGE_AUSWAHLCHOERE, ); + // Die ganze Liste auf einmal: es gibt eine Handvoll Auswahlchöre, und nur so + // greifen Sortierung und Spaltenfilter über alle Zeilen. const { data: auswahlchoereData, isLoading: auswahlchoereLoading, refetch, - } = api.auswahlchoere.getAll.useQuery({ - search: search || undefined, - page, - limit, - }); + } = api.auswahlchoere.getAll.useQuery({ page: 1, limit: 100 }); const deleteMutation = api.auswahlchoere.delete.useMutation({ onSuccess: () => { @@ -104,6 +98,126 @@ export default function DashboardAuswahlchoerePage() { deleteMutation.mutate({ id }); }; + const columns = useMemo[]>( + () => + column.columns([ + column.accessor((chor) => chor.name, { + id: "name", + header: "Auswahlchor", + meta: { alwaysVisible: true }, + cell: ({ row }) => { + const chor = row.original; + return ( +
+ {chor.image?.url ? ( +
+ {chor.name} +
+ ) : ( +
+ +
+ )} +
+ + {chor.name} + +

+ {chor.slug} +

+
+
+ ); + }, + }), + column.accessor((chor) => chor.subtitle ?? "", { + id: "subtitle", + header: "Untertitel", + }), + column.accessor( + (chor) => chor.conductor?.displayName ?? chor.conductor?.email ?? "", + { + id: "conductor", + header: "Leitung", + cell: ({ getValue }) => + getValue() || ( + + Keine Leitung + + ), + }, + ), + column.accessor( + (chor) => (chor.showApplication ? "Aktiv" : "Inaktiv"), + { + id: "application", + header: "Bewerbung", + meta: { filterVariant: "set" }, + cell: ({ row, getValue }) => ( + + {getValue()} + + ), + }, + ), + column.display({ + id: "actions", + header: "Aktionen", + meta: { align: "right", label: "Aktionen" }, + cell: ({ row }) => ( +
+ + {row.original.showApplication ? ( + + ) : ( + + )} + + + + + +
+ ), + }), + ]), + // eslint-disable-next-line react-hooks/exhaustive-deps + [deletingId], + ); + if (isPending || profileLoading || auswahlchoereLoading) { return (
@@ -116,8 +230,6 @@ export default function DashboardAuswahlchoerePage() { return null; } - const auswahlchoere = auswahlchoereData?.auswahlchoere ?? []; - return ( } > - {/* Filters */} -
-
- {/* Search */} -
-
- - { - setSearch(e.target.value); - setPage(1); - }} - placeholder="Auswahlchor suchen..." - className="dark:border-dark-border dark:bg-dark-background dark:text-dark-text w-full rounded-lg border border-gray-300 py-2 pr-4 pl-10 focus:border-transparent focus:ring-2 focus:ring-blue-500" - /> -
-
-
-
- - {/* Auswahlchöre List */} - {auswahlchoere.length === 0 ? ( -
-
- -
-

- Keine Auswahlchöre gefunden -

-

- {search - ? "Keine Auswahlchöre entsprechen deinen Filterkriterien." - : "Erstelle den ersten Auswahlchor, um ihn hier anzuzeigen."} -

- {!search && ( + chor.id} + isLoading={auswahlchoereLoading} + rowNoun={["Auswahlchor", "Auswahlchöre"]} + searchPlaceholder="Auswahlchor oder Leitung suchen…" + initialSorting={[{ id: "name", desc: false }]} + emptyState={ + <> + +

+ Keine Auswahlchöre gefunden +

+

+ Erstelle den ersten Auswahlchor, um ihn hier anzuzeigen. +

Auswahlchor erstellen - )} -
- ) : ( -
-
- - - - - - - - - - - - {auswahlchoere.map((chor) => ( - - - - - - - - ))} - -
- Auswahlchor - - Untertitel - - Leitung - - Bewerbung - - Aktionen -
-
- {chor.image?.url ? ( -
- {chor.name} -
- ) : ( -
- -
- )} -
- - {chor.name} - -

- {chor.slug} -

-
-
-
- - {chor.subtitle} - - - {chor.conductor ? ( - - {chor.conductor.displayName || chor.conductor.email} - - ) : ( - - Keine Leitung - - )} - - - {chor.showApplication ? "Aktiv" : "Inaktiv"} - - -
- - {chor.showApplication ? ( - - ) : ( - - )} - - - - - -
-
-
- {/* Pagination & Results count */} -
-
- {auswahlchoereData?.total} Auswahlchor - {auswahlchoereData?.total !== 1 && "e"} gefunden - {auswahlchoereData && auswahlchoereData.pages > 1 && ( - - {" "} - · Seite {page} von {auswahlchoereData.pages} - - )} -
- {auswahlchoereData && auswahlchoereData.pages > 1 && ( -
- -
- {Array.from( - { length: Math.min(5, auswahlchoereData.pages) }, - (_, i) => { - let pageNum: number; - if (auswahlchoereData.pages <= 5) { - pageNum = i + 1; - } else if (page <= 3) { - pageNum = i + 1; - } else if (page >= auswahlchoereData.pages - 2) { - pageNum = auswahlchoereData.pages - 4 + i; - } else { - pageNum = page - 2 + i; - } - return ( - - ); - }, - )} -
- -
- )} -
-
- )} + + } + />
); } diff --git a/src/app/dashboard/bezirke/page.tsx b/src/app/dashboard/bezirke/page.tsx index 945e032c..91286617 100644 --- a/src/app/dashboard/bezirke/page.tsx +++ b/src/app/dashboard/bezirke/page.tsx @@ -2,12 +2,17 @@ import { useSession } from "@/lib/auth"; import { useRouter } from "next/navigation"; -import { useEffect, useRef } from "react"; -import { api } from "@/trpc/react"; +import { useEffect, useMemo, useRef } from "react"; +import { api, type RouterOutputs } from "@/trpc/react"; import { usePermissions } from "@/lib/use-permissions"; import { PERMISSIONS } from "@/lib/permissions"; import Link from "next/link"; import { DashboardPage } from "@/app/_components/dashboard"; +import { + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; import { BookIcon, CalendarIcon, @@ -17,6 +22,31 @@ import { PencilIcon, } from "lucide-react"; +type Bezirk = RouterOutputs["bezirke"]["getAll"][number]; + +const column = createDataTableColumnHelper(); + +/** `_count` fehlt in manchen Varianten der Abfrage — dann zählt alles als 0. */ +function bezirkCounts(bezirk: Bezirk): { + ensembles: number; + events: number; + courses: number; +} { + const counts = + "_count" in bezirk + ? (bezirk._count as { + ensembles?: number; + events?: number; + courses?: number; + }) + : {}; + return { + ensembles: counts.ensembles ?? 0, + events: counts.events ?? 0, + courses: counts.courses ?? 0, + }; +} + export default function DashboardBezirkePage() { const router = useRouter(); const { data: session, isPending } = useSession(); @@ -55,6 +85,139 @@ export default function DashboardBezirkePage() { } }, [profile, profileLoading, permissionsLoading, canManageBezirke, router]); + const columns = useMemo[]>( + () => + column.columns([ + column.accessor((bezirk) => bezirk.number, { + id: "number", + header: "Nr.", + enableColumnFilter: false, + meta: { alwaysVisible: true, label: "Nummer" }, + cell: ({ row }) => ( + + {row.original.number} + + ), + }), + column.accessor((bezirk) => bezirk.name ?? "", { + id: "name", + header: "Bezirk", + meta: { alwaysVisible: true }, + cell: ({ row }) => ( +
+ + {row.original.name} + +

+ {row.original.shortName} +

+
+ ), + }), + column.accessor( + (bezirk) => bezirk.obleute.map((person) => person.name).join(", "), + { + id: "obleute", + header: "Obleute", + cell: ({ row }) => { + const obleute = row.original.obleute; + return ( +
+ {obleute.length > 0 ? ( + obleute.slice(0, 2).map((person) => ( + + {person.name} + + ({person.roleName}) + + + )) + ) : ( + + Keine Obleute zugewiesen + + )} + {obleute.length > 2 && ( + + +{obleute.length - 2} weitere + + )} +
+ ); + }, + }, + ), + column.accessor((bezirk) => bezirkCounts(bezirk).ensembles, { + id: "ensembles", + header: "Ensembles", + meta: { align: "right", filterVariant: "number" }, + cell: ({ getValue }) => ( + + + {getValue()} + + ), + }), + column.accessor((bezirk) => bezirkCounts(bezirk).events, { + id: "events", + header: "Termine", + meta: { align: "right", filterVariant: "number" }, + cell: ({ getValue }) => ( + + + {getValue()} + + ), + }), + column.accessor((bezirk) => bezirkCounts(bezirk).courses, { + id: "courses", + header: "Kurse", + meta: { align: "right", filterVariant: "number" }, + cell: ({ getValue }) => ( + + + {getValue()} + + ), + }), + column.display({ + id: "actions", + header: "Aktionen", + meta: { align: "right", label: "Aktionen" }, + cell: ({ row }) => ( +
+ + + + + + +
+ ), + }), + ]), + [], + ); + if (isPending || profileLoading || bezirkeLoading) { return (
@@ -76,170 +239,26 @@ export default function DashboardBezirkePage() { { label: "Bezirke" }, ]} > - {/* Bezirke List */} - {!bezirke || bezirke.length === 0 ? ( -
-
- -
-

- Keine Bezirke vorhanden -

-

- Die Bezirke wurden noch nicht in der Datenbank angelegt. -

-
- ) : ( -
-
- - - - - - - - - - - - {bezirke.map((bezirk) => ( - - - - - - - - ))} - -
- Nr. - - Bezirk - - Obleute - - Statistiken - - Aktionen -
- - {bezirk.number} - - -
- - {bezirk.name} - -

- {bezirk.shortName} -

-
-
-
- {bezirk.obleute.length > 0 ? ( - bezirk.obleute.slice(0, 2).map((person) => ( - - {person.name} - - ({person.roleName}) - - - )) - ) : ( - - Keine Obleute zugewiesen - - )} - {bezirk.obleute.length > 2 && ( - - +{bezirk.obleute.length - 2} weitere - - )} -
-
-
- {"_count" in bezirk && ( - <> - - - { - (bezirk._count as { ensembles: number }) - .ensembles - }{" "} - Ensembles - - - - { - (bezirk._count as { events: number }).events - }{" "} - Termine - - - - { - (bezirk._count as { courses: number }).courses - }{" "} - Kurse - - - )} -
-
-
- - - - - - - - - -
-
-
-
- )} + bezirk.id} + isLoading={bezirkeLoading} + rowNoun={["Bezirk", "Bezirke"]} + searchPlaceholder="Bezirk oder Obmann/Obfrau suchen…" + initialSorting={[{ id: "number", desc: false }]} + emptyState={ + <> + +

+ Keine Bezirke vorhanden +

+

+ Die Bezirke wurden noch nicht in der Datenbank angelegt. +

+ + } + /> ); } diff --git a/src/app/dashboard/blaeserhefte/page.tsx b/src/app/dashboard/blaeserhefte/page.tsx index 0583c5cb..2653ad1c 100644 --- a/src/app/dashboard/blaeserhefte/page.tsx +++ b/src/app/dashboard/blaeserhefte/page.tsx @@ -1,16 +1,21 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useSession } from "@/lib/auth"; import { useToast } from "@/app/_components/ui/toast"; import { useRouter } from "next/navigation"; import { useEffect, useRef } from "react"; -import { api } from "@/trpc/react"; +import { api, type RouterOutputs } from "@/trpc/react"; import { usePermissions } from "@/lib/use-permissions"; import { PERMISSIONS } from "@/lib/permissions"; import Link from "next/link"; import Image from "next/image"; import { DashboardPage } from "@/app/_components/dashboard"; +import { + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; import { EyeIcon, FileIcon, @@ -19,6 +24,22 @@ import { TrashIcon, } from "lucide-react"; +type Blaeserheft = RouterOutputs["materials"]["getBlaserhefte"][number]; + +const column = createDataTableColumnHelper(); + +/** Die lieferbaren Ausgaben als Text — Grundlage für Suche und Sortierung. */ +function availabilityList(heft: Blaeserheft): string { + return [ + heft.availableBlaeserheft ? "Heft" : null, + heft.availableBeiheft ? "Beiheft" : null, + heft.availableCd ? "CD" : null, + heft.availableTrompeten ? "Trompeten" : null, + ] + .filter(Boolean) + .join(", "); +} + export default function DashboardBlaeserheftePage() { const router = useRouter(); const { data: session, isPending } = useSession(); @@ -82,6 +103,132 @@ export default function DashboardBlaeserheftePage() { deleteMutation.mutate({ id }); }; + const columns = useMemo[]>( + () => + column.columns([ + column.accessor((heft) => heft.title, { + id: "title", + header: "Bläserheft", + meta: { alwaysVisible: true }, + cell: ({ row }) => { + const heft = row.original; + return ( +
+ {heft.image?.url ? ( +
+ {heft.title} +
+ ) : ( +
+ +
+ )} +
+ + {heft.title} + +

+ {heft.subtitle} +

+
+
+ ); + }, + }), + column.accessor((heft) => heft.year, { + id: "year", + header: "Jahr", + meta: { filterVariant: "set", align: "center" }, + cell: ({ getValue }) => ( + + {getValue()} + + ), + }), + column.accessor((heft) => heft.priceBlaeserheft ?? 0, { + id: "prices", + header: "Preise", + enableColumnFilter: false, + cell: ({ row }) => { + const heft = row.original; + return ( +
+ {heft.priceBlaeserheft && ( +

Heft: {heft.priceBlaeserheft} €

+ )} + {heft.priceBeiheft &&

Beiheft: {heft.priceBeiheft} €

} + {heft.priceCd &&

CD: {heft.priceCd} €

} +
+ ); + }, + }), + column.accessor(availabilityList, { + id: "availability", + header: "Verfügbarkeit", + cell: ({ row }) => ( +
+ {availabilityList(row.original) + .split(", ") + .filter(Boolean) + .map((label) => ( + + {label} + + ))} +
+ ), + }), + column.display({ + id: "actions", + header: "Aktionen", + meta: { align: "right", label: "Aktionen" }, + cell: ({ row }) => ( +
+ + + + + + + +
+ ), + }), + ]), + // eslint-disable-next-line react-hooks/exhaustive-deps + [deletingId], + ); + if (isPending || profileLoading || hefteLoading) { return (
@@ -112,161 +259,33 @@ export default function DashboardBlaeserheftePage() { } > - {/* Hefte List */} - {!hefte || hefte.length === 0 ? ( -
-
- -
-

- Keine Bläserhefte vorhanden -

-

- Erstelle das erste Bläserheft, um es hier anzuzeigen. -

- - - Bläserheft erstellen - -
- ) : ( -
-
- - - - - - - - - - - - {hefte.map((heft) => ( - - - - - - - - ))} - -
- Bläserheft - - Jahr - - Preise - - Verfügbarkeit - - Aktionen -
-
- {heft.image?.url ? ( -
- {heft.title} -
- ) : ( -
- -
- )} -
- - {heft.title} - -

- {heft.subtitle} -

-
-
-
- - {heft.year} - - -
- {heft.priceBlaeserheft && ( -

Heft: {heft.priceBlaeserheft} €

- )} - {heft.priceBeiheft && ( -

Beiheft: {heft.priceBeiheft} €

- )} - {heft.priceCd &&

CD: {heft.priceCd} €

} -
-
-
- {heft.availableBlaeserheft && ( - - Heft - - )} - {heft.availableBeiheft && ( - - Beiheft - - )} - {heft.availableCd && ( - - CD - - )} - {heft.availableTrompeten && ( - - Trompeten - - )} -
-
-
- - - - - - - -
-
-
-
- )} + heft.id} + isLoading={hefteLoading} + rowNoun={["Bläserheft", "Bläserhefte"]} + searchPlaceholder="Titel oder Untertitel suchen…" + initialSorting={[{ id: "year", desc: true }]} + emptyState={ + <> + +

+ Keine Bläserhefte vorhanden +

+

+ Erstelle das erste Bläserheft, um es hier anzuzeigen. +

+ + + Bläserheft erstellen + + + } + /> ); } diff --git a/src/app/dashboard/downloads/page.tsx b/src/app/dashboard/downloads/page.tsx index 2e28e05f..eb6c8b82 100644 --- a/src/app/dashboard/downloads/page.tsx +++ b/src/app/dashboard/downloads/page.tsx @@ -3,11 +3,16 @@ import { Select } from "@/app/_components/ui"; import { useSession } from "@/lib/auth"; import { redirect } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; -import { api } from "@/trpc/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { api, type RouterOutputs } from "@/trpc/react"; import { usePermissions } from "@/lib/use-permissions"; import type { PermissionKey } from "@/lib/permissions"; import { DashboardPage } from "@/app/_components/dashboard"; +import { + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; import { useToast } from "@/app/_components/ui/toast"; import { ContentStatus, @@ -25,6 +30,11 @@ import { // Dashboard access is now controlled by permissions +type DashboardDownload = + RouterOutputs["materials"]["getDownloads"]["downloads"][number]; + +const column = createDataTableColumnHelper(); + const statusLabels: Record = { DRAFT: "Entwurf", PENDING: "Ausstehend", @@ -79,14 +89,6 @@ export default function DashboardDownloadsPage() { const toast = useToast(); const hasRedirected = useRef(false); - const [search, setSearch] = useState(""); - const [categoryFilter, setCategoryFilter] = useState( - "", - ); - const [statusFilter, setStatusFilter] = useState(""); - const [page, setPage] = useState(1); - const limit = 20; - const [showUploadModal, setShowUploadModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(null); const [showEditModal, setShowEditModal] = useState(null); @@ -131,14 +133,10 @@ export default function DashboardDownloadsPage() { const utils = api.useUtils(); + // Die ganze Liste auf einmal: Kategorie, Status und Suche sind jetzt Filter + // der Tabelle und müssen über alle Zeilen greifen, nicht nur über eine Seite. const { data, isLoading } = api.materials.getDownloads.useQuery( - { - page, - limit, - category: categoryFilter || undefined, - search: search || undefined, - includeAll: true, - }, + { page: 1, limit: 100, includeAll: true }, { enabled: !!profile }, ); @@ -354,6 +352,133 @@ export default function DashboardDownloadsPage() { }); }; + const isReviewer = hasApprovePermission; + const canDelete = hasDeletePermission; + + const columns = useMemo[]>( + () => + column.columns([ + column.accessor((download) => download.title, { + id: "title", + header: "Datei", + meta: { alwaysVisible: true }, + cell: ({ row }) => { + const download = row.original; + return ( +
+ + {fileTypeIcons[download.fileType]} + +
+

+ {download.title} +

+

+ {fileTypeLabels[download.fileType]} + {download.fileSize && + ` • ${formatFileSize(download.fileSize)}`} +

+
+
+ ); + }, + }), + column.accessor((download) => categoryLabels[download.category], { + id: "category", + header: "Kategorie", + meta: { filterVariant: "set" }, + }), + column.accessor((download) => fileTypeLabels[download.fileType], { + id: "fileType", + header: "Typ", + meta: { filterVariant: "set" }, + }), + column.accessor((download) => download.fileSize ?? 0, { + id: "fileSize", + header: "Größe", + meta: { align: "right", filterVariant: "number", label: "Größe" }, + cell: ({ getValue }) => + getValue() ? formatFileSize(getValue()) : "—", + }), + column.accessor((download) => statusLabels[download.status], { + id: "status", + header: "Status", + meta: { filterVariant: "set" }, + cell: ({ row, getValue }) => ( + + {getValue()} + + ), + }), + column.accessor( + (download) => download.uploadedBy?.displayName ?? "Unbekannt", + { + id: "uploadedBy", + header: "Hochgeladen von", + meta: { filterVariant: "set" }, + }, + ), + column.display({ + id: "actions", + header: "Aktionen", + meta: { align: "right", label: "Aktionen" }, + cell: ({ row }) => { + const download = row.original; + return ( +
+ + + + {isReviewer && ( + + )} + {isReviewer && download.status === ContentStatus.PENDING && ( + + )} + {canDelete && ( + + )} +
+ ); + }, + }), + ]), + // eslint-disable-next-line react-hooks/exhaustive-deps + [isReviewer, canDelete, reviewMutation.isPending], + ); + if (isPending || profileLoading || permissionsLoading) { return (
@@ -366,13 +491,6 @@ export default function DashboardDownloadsPage() { return null; } - const isReviewer = hasApprovePermission; - const canDelete = hasDeletePermission; - - const filteredDownloads = statusFilter - ? data?.downloads.filter((d) => d.status === statusFilter) - : data?.downloads; - return ( <> } > - {/* Filters */} -
-
- {/* Search */} -
- { - setSearch(e.target.value); - setPage(1); - }} - className="dark:bg-dark-background dark:border-dark-border dark:text-dark-text focus:border-primary focus:ring-primary w-full rounded-lg border border-gray-300 px-4 py-2 focus:ring-1 focus:outline-none" - /> -
- - {/* Category Filter */} - - - {/* Status Filter */} - {isReviewer && ( - - )} -
-
- - {/* Downloads List */} - {isLoading ? ( -
-
-
- ) : !filteredDownloads?.length ? ( -
- -

- Keine Downloads gefunden -

-
- ) : ( -
- - - - - - - - - - - - {filteredDownloads.map((download) => ( - - - - - - - - ))} - -
- Datei - - Kategorie - - Status - - Hochgeladen von - - Aktionen -
-
- - {fileTypeIcons[download.fileType]} - -
-

- {download.title} -

-

- {fileTypeLabels[download.fileType]} - {download.fileSize && - ` • ${formatFileSize(download.fileSize)}`} -

-
-
-
- - {categoryLabels[download.category]} - - - - {statusLabels[download.status]} - - - - {download.uploadedBy?.displayName ?? "Unbekannt"} - - -
- {/* Download link */} - - - - - {/* Edit button for reviewers */} - {isReviewer && ( - - )} - - {/* Approve button for reviewers */} - {isReviewer && - download.status === ContentStatus.PENDING && ( - - )} - - {/* Delete button */} - {canDelete && ( - - )} -
-
-
- )} - - {/* Pagination */} - {data && data.pages > 1 && ( -
-

- Seite {page} von {data.pages} ({data.total} Downloads) -

-
- - -
-
- )} + download.id} + isLoading={isLoading} + rowNoun={["Download", "Downloads"]} + searchPlaceholder="Titel oder Beschreibung suchen…" + initialSorting={[{ id: "title", desc: false }]} + emptyState={ + <> + +

+ Keine Downloads gefunden +

+ + } + /> {/* Upload Modal */} diff --git a/src/app/dashboard/ensembles/page.tsx b/src/app/dashboard/ensembles/page.tsx index 82fb8813..2ff5ba2b 100644 --- a/src/app/dashboard/ensembles/page.tsx +++ b/src/app/dashboard/ensembles/page.tsx @@ -1,39 +1,52 @@ "use client"; -import { Select } from "@/app/_components/ui"; - -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useSession } from "@/lib/auth"; import { useToast } from "@/app/_components/ui/toast"; import { useRouter } from "next/navigation"; import { useEffect, useRef } from "react"; -import { keepPreviousData } from "@tanstack/react-query"; -import { api } from "@/trpc/react"; +import { api, type RouterOutputs } from "@/trpc/react"; import { usePermissions } from "@/lib/use-permissions"; import type { PermissionKey } from "@/lib/permissions"; import Link from "next/link"; import Image from "next/image"; import { DashboardPage } from "@/app/_components/dashboard"; import { - ArrowLeftIcon, - EditIcon, - MusicIcon, - PlusIcon, - SearchIcon, -} from "lucide-react"; -import { ArrowRightIcon, EyeIcon, TrashIcon } from "lucide-react"; + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; +import { EditIcon, MusicIcon, PlusIcon } from "lucide-react"; +import { EyeIcon, TrashIcon } from "lucide-react"; + +type DashboardEnsemble = + RouterOutputs["ensembles"]["getAll"]["ensembles"][number]; + +const column = createDataTableColumnHelper(); + +/** Eingetragene Leitung, sonst der verknüpfte Benutzer. */ +function conductorName(ensemble: DashboardEnsemble): string { + return ensemble.conductorName ?? ensemble.conductor?.displayName ?? ""; +} + +/** Probenzeiten als eine Zeile — auch die Sortier- und Suchgrundlage. */ +function rehearsalSummary(ensemble: DashboardEnsemble): string { + if (ensemble.rehearsalSchedules?.length) { + return ensemble.rehearsalSchedules + .map((schedule) => + [schedule.day, schedule.time].filter(Boolean).join(", "), + ) + .join(" · "); + } + return [ensemble.rehearsalDay, ensemble.rehearsalTime] + .filter(Boolean) + .join(", "); +} export default function DashboardEnsemblesPage() { const router = useRouter(); const { data: session, isPending } = useSession(); const hasRedirected = useRef(false); const [deletingId, setDeletingId] = useState(null); - const [searchInput, setSearchInput] = useState(""); - const [search, setSearch] = useState(""); - const [selectedBezirk, setSelectedBezirk] = useState(""); - const [showInactive, setShowInactive] = useState(false); - const [page, setPage] = useState(1); - const limit = 20; - const toast = useToast(); const { data: profile, isLoading: profileLoading } = @@ -46,34 +59,15 @@ export default function DashboardEnsemblesPage() { "ensembles.delete" as PermissionKey, ]); - useEffect(() => { - const timer = setTimeout(() => { - const next = searchInput.trim(); - setSearch((prev) => { - if (prev !== next) setPage(1); - return next; - }); - }, 300); - return () => clearTimeout(timer); - }, [searchInput]); - + // Die ganze Liste auf einmal: Ensembles sind eine überschaubare Stammdatei, + // und nur so greifen Sortierung und Spaltenfilter über alle Zeilen statt nur + // über die gerade sichtbare Seite. Inaktive kommen mit und lassen sich über + // den Status-Filter in der Spalte ausblenden. const { data: ensemblesData, isLoading: ensemblesLoading, - isFetching: ensemblesFetching, refetch, - } = api.ensembles.getAll.useQuery( - { - search: search || undefined, - bezirkId: selectedBezirk || undefined, - isActive: showInactive ? undefined : true, - page, - limit, - }, - { placeholderData: keepPreviousData }, - ); - - const { data: bezirke } = api.bezirke.getAll.useQuery(); + } = api.ensembles.getAll.useQuery({ page: 1, limit: 500 }); const deleteMutation = api.ensembles.delete.useMutation({ onSuccess: () => { @@ -118,10 +112,174 @@ export default function DashboardEnsemblesPage() { deleteMutation.mutate({ id }); }; - const isInitialEnsemblesLoad = - ensemblesLoading && ensemblesData === undefined; + const columns = useMemo[]>( + () => + column.columns([ + column.accessor((ensemble) => ensemble.name, { + id: "name", + header: "Ensemble", + meta: { alwaysVisible: true }, + cell: ({ row }) => { + const ensemble = row.original; + return ( +
+ {ensemble.image?.url ? ( +
+ {ensemble.name} +
+ ) : ( +
+ +
+ )} +
+ + {ensemble.name} + + {ensemble.location && ( +

+ {ensemble.location.city} +

+ )} +
+
+ ); + }, + }), + column.accessor((ensemble) => ensemble.bezirk?.shortName ?? "", { + id: "bezirk", + header: "Bezirk", + meta: { filterVariant: "set" }, + cell: ({ row }) => + row.original.bezirk ? ( + + {row.original.bezirk.shortName} + + ) : ( + + Kein Bezirk + + ), + }), + column.accessor(conductorName, { + id: "conductor", + header: "Leitung", + cell: ({ getValue }) => + getValue() || ( + + Keine Leitung + + ), + }), + column.accessor(rehearsalSummary, { + id: "rehearsal", + header: "Probe", + cell: ({ row }) => { + const ensemble = row.original; + if (ensemble.rehearsalSchedules?.length) { + return ( +
+ {ensemble.rehearsalSchedules.map((schedule, index) => ( +
+ {schedule.day} + {schedule.time && `, ${schedule.time}`} +
+ ))} +
+ ); + } + const fallback = rehearsalSummary(ensemble); + return fallback ? ( + + {fallback} + + ) : ( + + – + + ); + }, + }), + column.accessor( + (ensemble) => (ensemble.isActive ? "Aktiv" : "Inaktiv"), + { + id: "status", + header: "Status", + meta: { filterVariant: "set" }, + cell: ({ row }) => ( + + {row.original.isActive ? "Aktiv" : "Inaktiv"} + + ), + }, + ), + column.display({ + id: "actions", + header: "Aktionen", + meta: { align: "right", label: "Aktionen" }, + cell: ({ row }) => ( +
+ + + + + + + {hasManagePermission && ( + + )} +
+ ), + }), + ]), + // eslint-disable-next-line react-hooks/exhaustive-deps + [deletingId, hasManagePermission], + ); - if (isPending || profileLoading || isInitialEnsemblesLoad) { + if (isPending || profileLoading) { return (
@@ -133,9 +291,6 @@ export default function DashboardEnsemblesPage() { return null; } - const ensembles = ensemblesData?.ensembles ?? []; - const isAdmin = hasManagePermission; - return ( } > - {/* Filters */} -
-
- {/* Search */} -
-
- - setSearchInput(e.target.value)} - placeholder="Ensemble suchen..." - className="dark:border-dark-border dark:bg-dark-background dark:text-dark-text w-full rounded-lg border border-gray-300 py-2 pr-4 pl-10 focus:border-transparent focus:ring-2 focus:ring-blue-500" - /> -
-
- - {/* Bezirk Filter */} -
- -
- - {/* Show Inactive Toggle */} - -
-
- - {/* Ensembles List */} -
- {ensembles.length === 0 ? ( -
-
- -
-

+ ensemble.id} + isLoading={ensemblesLoading} + rowNoun={["Ensemble", "Ensembles"]} + searchPlaceholder="Ensemble, Ort oder Leitung suchen…" + initialSorting={[{ id: "name", desc: false }]} + emptyState={ + <> + +

Keine Ensembles gefunden

- {search || selectedBezirk - ? "Keine Ensembles entsprechen deinen Filterkriterien." - : "Erstelle das erste Ensemble, um es hier anzuzeigen."} + Erstelle das erste Ensemble, um es hier anzuzeigen.

- {!search && !selectedBezirk && ( - - - Ensemble erstellen - - )} -

- ) : ( -
-
- - - - - - - - - - - - - {ensembles.map((ensemble) => ( - - - - - - - - - ))} - -
- Ensemble - - Bezirk - - Leitung - - Probe - - Status - - Aktionen -
-
- {ensemble.image?.url ? ( -
- {ensemble.name} -
- ) : ( -
- -
- )} -
- - {ensemble.name} - - {ensemble.location && ( -

- {ensemble.location.city} -

- )} -
-
-
- {ensemble.bezirk ? ( - - {ensemble.bezirk.shortName} - - ) : ( - - Kein Bezirk - - )} - - {ensemble.conductorName ? ( - - {ensemble.conductorName} - - ) : ensemble.conductor ? ( - - {ensemble.conductor.displayName} - - ) : ( - - Keine Leitung - - )} - - {ensemble.rehearsalSchedules && - ensemble.rehearsalSchedules.length > 0 ? ( -
- {ensemble.rehearsalSchedules.map( - (schedule, index) => ( -
- {schedule.day} - {schedule.time && `, ${schedule.time}`} -
- ), - )} -
- ) : ensemble.rehearsalDay || ensemble.rehearsalTime ? ( - - {ensemble.rehearsalDay} - {ensemble.rehearsalDay && - ensemble.rehearsalTime && - ", "} - {ensemble.rehearsalTime} - - ) : ( - - – - - )} -
- - {ensemble.isActive ? "Aktiv" : "Inaktiv"} - - -
- - - - - - - {isAdmin && ( - - )} -
-
-
- {/* Pagination & Results count */} -
-
- {ensemblesData?.total} Ensemble - {ensemblesData?.total !== 1 && "s"} gefunden - {ensemblesData && ensemblesData.pages > 1 && ( - - {" "} - · Seite {page} von {ensemblesData.pages} - - )} -
- {ensemblesData && ensemblesData.pages > 1 && ( -
- -
- {Array.from( - { length: Math.min(5, ensemblesData.pages) }, - (_, i) => { - let pageNum: number; - if (ensemblesData.pages <= 5) { - pageNum = i + 1; - } else if (page <= 3) { - pageNum = i + 1; - } else if (page >= ensemblesData.pages - 2) { - pageNum = ensemblesData.pages - 4 + i; - } else { - pageNum = page - 2 + i; - } - return ( - - ); - }, - )} -
- -
- )} -
-
- )} -
+ + + Ensemble erstellen + + + } + />
); } diff --git a/src/app/dashboard/foerderverein/page.tsx b/src/app/dashboard/foerderverein/page.tsx index f65259ee..b4838fd5 100644 --- a/src/app/dashboard/foerderverein/page.tsx +++ b/src/app/dashboard/foerderverein/page.tsx @@ -1,16 +1,21 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useSession } from "@/lib/auth"; import { useToast } from "@/app/_components/ui/toast"; import { useRouter } from "next/navigation"; import { useEffect, useRef } from "react"; -import { api } from "@/trpc/react"; +import { api, type RouterOutputs } from "@/trpc/react"; import { usePermissions } from "@/lib/use-permissions"; import { PERMISSIONS } from "@/lib/permissions"; import Link from "next/link"; import Image from "next/image"; import { DashboardPage } from "@/app/_components/dashboard"; +import { + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; import { ChevronDownIcon, ChevronUpIcon, @@ -31,6 +36,16 @@ const FOERDERVEREIN_ROLE_LABELS: Record = { MITGLIED: "Mitglied", }; +type FoerdervereinMember = + RouterOutputs["organization"]["getFoerderverein"][number]; + +const column = createDataTableColumnHelper(); + +/** Der verknüpfte Benutzername schlägt den frei eingetragenen. */ +function memberName(member: FoerdervereinMember): string { + return member.user?.displayName ?? member.name ?? "Unbekannt"; +} + export default function DashboardFoerdervereinPage() { const router = useRouter(); const { data: session, isPending } = useSession(); @@ -131,6 +146,169 @@ export default function DashboardFoerdervereinPage() { } }; + const columns = useMemo[]>( + () => + column.columns([ + // Die gespeicherte Reihenfolge als eigene Spalte: nur so bleiben die + // Hoch/Runter-Pfeile nachvollziehbar, wenn nach etwas anderem sortiert + // wird — sie verschieben immer die gespeicherte Position, nie die Sicht. + column.accessor((member) => (members?.indexOf(member) ?? 0) + 1, { + id: "position", + header: "#", + enableColumnFilter: false, + meta: { align: "right", label: "Reihenfolge" }, + }), + column.accessor(memberName, { + id: "member", + header: "Mitglied", + meta: { alwaysVisible: true }, + cell: ({ row }) => { + const member = row.original; + const displayName = memberName(member); + const imageUrl = + member.user?.profileImage?.url ?? member.image?.url; + return ( +
+ {imageUrl ? ( +
+ {displayName} +
+ ) : ( +
+ +
+ )} +
+ + {displayName} + +

+ {member.user?.email ?? member.email ?? "-"} +

+
+
+ ); + }, + }), + column.accessor( + (member) => FOERDERVEREIN_ROLE_LABELS[member.role] ?? member.role, + { + id: "role", + header: "Position / Rolle", + meta: { filterVariant: "set", label: "Rolle" }, + cell: ({ row, getValue }) => ( +
+ {row.original.position && ( + + {row.original.position} + + )} + + {getValue()} + +
+ ), + }, + ), + column.accessor((member) => member.memberSince, { + id: "memberSince", + header: "Mitglied seit", + sortFn: "datetime", + sortUndefined: "last", + meta: { filterVariant: "date", cellClassName: "whitespace-nowrap" }, + cell: ({ getValue }) => { + const value = getValue(); + return value + ? new Date(value).toLocaleDateString("de-DE", { + year: "numeric", + month: "long", + }) + : "-"; + }, + }), + column.accessor((member) => (member.user ? "Verknüpft" : "Manuell"), { + id: "linked", + header: "Verknüpfung", + meta: { filterVariant: "set" }, + cell: ({ row }) => + row.original.user ? ( + + + Verknüpft + + ) : ( + + + Manuell + + ), + }), + column.display({ + id: "actions", + header: "Aktionen", + meta: { align: "right", label: "Aktionen" }, + cell: ({ row }) => { + const index = members?.indexOf(row.original) ?? -1; + return ( +
+ + + + + + +
+ ); + }, + }), + ]), + // eslint-disable-next-line react-hooks/exhaustive-deps + [members, deletingId, isReordering], + ); + if (isPending || profileLoading || membersLoading) { return (
@@ -161,179 +339,32 @@ export default function DashboardFoerdervereinPage() { } > - {/* Members List */} - {!members || members.length === 0 ? ( -
-
- -
-

- Keine Fördervereinsmitglieder -

-

- Es wurden noch keine Fördervereinsmitglieder angelegt. -

- - Erstes Mitglied anlegen - -
- ) : ( -
-
- - - - - - - - - - - - {members.map((member, index) => { - const displayName = - member.user?.displayName || member.name || "Unbekannt"; - const displayEmail = - member.user?.email || member.email || "-"; - const imageUrl = - member.user?.profileImage?.url || member.image?.url; - - return ( - - - - - - - - ); - })} - -
- Mitglied - - Position / Rolle - - Mitglied seit - - Verknüpfung - - Aktionen -
-
- {imageUrl ? ( -
- {displayName} -
- ) : ( -
- -
- )} -
- - {displayName} - -

- {displayEmail} -

-
-
-
-
- {member.position && ( - - {member.position} - - )} - - {FOERDERVEREIN_ROLE_LABELS[member.role] || - member.role} - -
-
- {member.memberSince - ? new Date(member.memberSince).toLocaleDateString( - "de-DE", - { - year: "numeric", - month: "long", - }, - ) - : "-"} - - {member.user ? ( - - - Verknüpft - - ) : ( - - - Manuell - - )} - -
- - - - - - -
-
-
-
- )} + member.id} + isLoading={membersLoading} + rowNoun={["Mitglied", "Mitglieder"]} + searchPlaceholder="Name, E-Mail oder Position suchen…" + initialSorting={[{ id: "position", desc: false }]} + emptyState={ + <> + +

+ Keine Fördervereinsmitglieder +

+

+ Es wurden noch keine Fördervereinsmitglieder angelegt. +

+ + Erstes Mitglied anlegen + + + } + /> ); } diff --git a/src/app/dashboard/history-timeline/page.tsx b/src/app/dashboard/history-timeline/page.tsx index 282b1b9a..3f775050 100644 --- a/src/app/dashboard/history-timeline/page.tsx +++ b/src/app/dashboard/history-timeline/page.tsx @@ -1,28 +1,40 @@ "use client"; -import { Select } from "@/app/_components/ui"; - -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useSession } from "@/lib/auth"; import { useToast } from "@/app/_components/ui/toast"; import { useRouter } from "next/navigation"; import { useEffect, useRef } from "react"; -import { api } from "@/trpc/react"; +import { api, type RouterOutputs } from "@/trpc/react"; import { usePermissions } from "@/lib/use-permissions"; import { PERMISSIONS } from "@/lib/permissions"; import Link from "next/link"; import Image from "next/image"; import { DashboardPage } from "@/app/_components/dashboard"; -import { Plus } from "lucide-react"; +import { + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; +import { ClockIcon, EyeIcon, PencilIcon, Plus, Trash2Icon } from "lucide-react"; // Dashboard access is now controlled by permissions +type HistoryEvent = RouterOutputs["organization"]["getHistory"][number]; + +const CATEGORY_LABELS: Record = { + FOUNDING: "Gründung", + MILESTONE: "Meilenstein", + EXPANSION: "Erweiterung", + MODERNIZATION: "Modernisierung", + PARTNERSHIP: "Partnerschaft", +}; + +const column = createDataTableColumnHelper(); + export default function DashboardHistoryTimelinePage() { const router = useRouter(); const { data: session, isPending } = useSession(); const hasRedirected = useRef(false); const [deletingId, setDeletingId] = useState(null); - const [search, setSearch] = useState(""); - const [categoryFilter, setCategoryFilter] = useState(""); - const toast = useToast(); const { data: profile, isLoading: profileLoading } = @@ -35,20 +47,12 @@ export default function DashboardHistoryTimelinePage() { PERMISSIONS.ORGANIZATION_MANAGE_HISTORY, ); + // Alle Kategorien auf einmal: die Kategorie ist jetzt ein Spaltenfilter. const { data: historyEvents, isLoading: historyLoading, refetch, - } = api.organization.getHistory.useQuery({ - category: categoryFilter - ? (categoryFilter as - | "FOUNDING" - | "MILESTONE" - | "EXPANSION" - | "MODERNIZATION" - | "PARTNERSHIP") - : undefined, - }); + } = api.organization.getHistory.useQuery({}); const deleteMutation = api.organization.deleteHistoryEvent.useMutation({ onSuccess: () => { @@ -100,6 +104,123 @@ export default function DashboardHistoryTimelinePage() { deleteMutation.mutate({ id }); }; + const columns = useMemo[]>( + () => + column.columns([ + column.accessor((event) => event.year, { + id: "year", + header: "Jahr", + meta: { filterVariant: "number", alwaysVisible: true }, + cell: ({ getValue }) => ( + + {getValue()} + + ), + }), + column.accessor((event) => `${event.title} ${event.description}`, { + id: "event", + header: "Ereignis", + meta: { alwaysVisible: true, label: "Ereignis" }, + cell: ({ row }) => ( +
+ + {row.original.title} + +

+ {row.original.description} +

+
+ ), + }), + column.accessor( + (event) => + event.category + ? (CATEGORY_LABELS[event.category] ?? event.category) + : "", + { + id: "category", + header: "Kategorie", + meta: { filterVariant: "set" }, + cell: ({ getValue }) => + getValue() ? ( + + {getValue()} + + ) : ( + + Keine Kategorie + + ), + }, + ), + column.accessor( + (event) => (event.image?.url ? "Mit Bild" : "Kein Bild"), + { + id: "image", + header: "Bild", + meta: { filterVariant: "set" }, + cell: ({ row }) => + row.original.image?.url ? ( +
+ {row.original.imageAlt +
+ ) : ( + + Kein Bild + + ), + }, + ), + column.display({ + id: "actions", + header: "Aktionen", + meta: { align: "right", label: "Aktionen" }, + cell: ({ row }) => ( +
+ + + + + + + +
+ ), + }), + ]), + // eslint-disable-next-line react-hooks/exhaustive-deps + [deletingId], + ); + if (isPending || profileLoading || historyLoading) { return (
@@ -112,25 +233,6 @@ export default function DashboardHistoryTimelinePage() { return null; } - const filteredEvents = - historyEvents?.filter((event) => { - if (!search) return true; - const searchLower = search.toLowerCase(); - return ( - event.title.toLowerCase().includes(searchLower) || - event.description.toLowerCase().includes(searchLower) || - event.year.toString().includes(searchLower) - ); - }) ?? []; - - const categoryLabels: Record = { - FOUNDING: "Gründung", - MILESTONE: "Meilenstein", - EXPANSION: "Erweiterung", - MODERNIZATION: "Modernisierung", - PARTNERSHIP: "Partnerschaft", - }; - return ( } > - {/* Filters */} -
-
- {/* Search */} -
-
- - - - setSearch(e.target.value)} - placeholder="Ereignis suchen..." - className="dark:border-dark-border dark:bg-dark-background dark:text-dark-text w-full rounded-lg border border-gray-300 py-2 pr-4 pl-10 focus:border-transparent focus:ring-2 focus:ring-blue-500" - /> -
-
- {/* Category Filter */} -
- -
-
-
- - {/* History Events List */} - {filteredEvents.length === 0 ? ( -
-
- - - -
-

- Keine Ereignisse gefunden -

-

- {search || categoryFilter - ? "Keine Ereignisse entsprechen deinen Filterkriterien." - : "Erstelle das erste Ereignis, um es hier anzuzeigen."} -

- {!search && !categoryFilter && ( + event.id} + isLoading={historyLoading} + rowNoun={["Ereignis", "Ereignisse"]} + searchPlaceholder="Ereignis, Jahr oder Beschreibung suchen…" + initialSorting={[{ id: "year", desc: true }]} + emptyState={ + <> + +

+ Keine Ereignisse gefunden +

+

+ Erstelle das erste Ereignis, um es hier anzuzeigen. +

- - - + Ereignis erstellen - )} -
- ) : ( -
-
- - - - - - - - - - - - {filteredEvents.map((event) => ( - - - - - - - - ))} - -
- Jahr - - Ereignis - - Kategorie - - Bild - - Aktionen -
- - {event.year} - - -
- - {event.title} - -

- {event.description} -

-
-
- {event.category ? ( - - {categoryLabels[event.category] || event.category} - - ) : ( - - Keine Kategorie - - )} - - {event.image?.url ? ( -
- {event.imageAlt -
- ) : ( - - Kein Bild - - )} -
-
- - - - - - - - - - - - -
-
-
- {/* Results count */} -
-
- {filteredEvents.length} Ereignis - {filteredEvents.length !== 1 && "se"} gefunden -
-
-
- )} + + } + />
); } diff --git a/src/app/dashboard/locations/page.tsx b/src/app/dashboard/locations/page.tsx index af5e74c0..f1a9c52d 100644 --- a/src/app/dashboard/locations/page.tsx +++ b/src/app/dashboard/locations/page.tsx @@ -1,47 +1,48 @@ "use client"; -import { useState } from "react"; -import { keepPreviousData } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; import { useSession } from "@/lib/auth"; import { useToast } from "@/app/_components/ui/toast"; import { useRouter } from "next/navigation"; import { useEffect, useRef } from "react"; -import { api } from "@/trpc/react"; +import { api, type RouterOutputs } from "@/trpc/react"; import { usePermissions } from "@/lib/use-permissions"; import { PERMISSIONS } from "@/lib/permissions"; import Link from "next/link"; import { DashboardPage } from "@/app/_components/dashboard"; import { - Plus, - Search, - MapPin, - Edit, - Trash2, - Eye, - ChevronLeft, - ChevronRight, -} from "lucide-react"; + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; +import { Plus, MapPin, Edit, Trash2, Eye } from "lucide-react"; + +type DashboardLocation = + RouterOutputs["locations"]["getAll"]["locations"][number]; + +const column = createDataTableColumnHelper(); + +/** Wie oft ein Standort gebucht ist — auch die Sortiergrundlage der Spalte. */ +function usageCount(location: DashboardLocation): number { + return ( + location._count.events + location._count.courses + location._count.ensembles + ); +} + +function addressLine(location: DashboardLocation): string { + return [ + location.street, + [location.zipCode, location.city].filter(Boolean).join(" "), + ] + .filter(Boolean) + .join(", "); +} export default function DashboardLocationsPage() { const router = useRouter(); const { data: session, isPending } = useSession(); const hasRedirected = useRef(false); const [deletingId, setDeletingId] = useState(null); - const [searchInput, setSearchInput] = useState(""); - const [search, setSearch] = useState(""); - const [page, setPage] = useState(1); - const limit = 20; - - useEffect(() => { - const timer = setTimeout(() => { - const next = searchInput.trim(); - setSearch((prev) => { - if (prev !== next) setPage(1); - return next; - }); - }, 300); - return () => clearTimeout(timer); - }, [searchInput]); const toast = useToast(); @@ -55,19 +56,14 @@ export default function DashboardLocationsPage() { PERMISSIONS.ORGANIZATION_MANAGE_LOCATIONS, ); + // Die ganze Liste auf einmal: Standorte sind eine überschaubare Stammdatei, + // und nur so können Sortierung und Spaltenfilter über alle Zeilen greifen + // statt nur über die gerade sichtbare Seite. const { data: locationsData, isLoading: locationsLoading, - isFetching: locationsFetching, refetch, - } = api.locations.getAll.useQuery( - { - search: search || undefined, - page, - limit, - }, - { placeholderData: keepPreviousData }, - ); + } = api.locations.getAll.useQuery({ page: 1, limit: 1000 }); const deleteMutation = api.locations.delete.useMutation({ onSuccess: () => { @@ -101,7 +97,7 @@ export default function DashboardLocationsPage() { } }, [profile, profileLoading, permissionsLoading, canManageLocations, router]); - const handleDelete = async (id: string, name: string) => { + const handleDelete = (id: string, name: string) => { if ( !confirm( `Möchtest du den Standort "${name || "Unbekannt"}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`, @@ -113,10 +109,129 @@ export default function DashboardLocationsPage() { deleteMutation.mutate({ id }); }; - const isInitialLocationsLoad = - locationsLoading && locationsData === undefined; + const columns = useMemo[]>( + () => + column.columns([ + column.accessor((location) => location.name || "Unbenannter Standort", { + id: "name", + header: "Standort", + meta: { alwaysVisible: true }, + cell: ({ row }) => ( +
+ + {row.original.name || "Unbenannter Standort"} + + {row.original.additionalInfo && ( +

+ {row.original.additionalInfo} +

+ )} +
+ ), + }), + column.accessor((location) => location.city ?? "", { + id: "city", + header: "Ort", + meta: { filterVariant: "set" }, + }), + column.accessor(addressLine, { + id: "address", + header: "Adresse", + cell: ({ getValue }) => + getValue() || ( + + Keine Adresse + + ), + }), + column.accessor(usageCount, { + id: "usage", + header: "Verwendung", + meta: { filterVariant: "number", label: "Verwendung" }, + cell: ({ row }) => { + const location = row.original; + if (usageCount(location) === 0) { + return ( + + Nicht verwendet + + ); + } + return ( +
+ {location._count.events > 0 && ( + + {location._count.events} Termin + {location._count.events !== 1 && "e"} + + )} + {location._count.courses > 0 && ( + + {location._count.courses} Kurs + {location._count.courses !== 1 && "e"} + + )} + {location._count.ensembles > 0 && ( + + {location._count.ensembles} Ensemble + {location._count.ensembles !== 1 && "s"} + + )} +
+ ); + }, + }), + column.display({ + id: "actions", + header: "Aktionen", + meta: { align: "right", label: "Aktionen" }, + cell: ({ row }) => ( +
+ + + + + + + +
+ ), + }), + ]), + // eslint-disable-next-line react-hooks/exhaustive-deps + [deletingId], + ); - if (isPending || profileLoading || isInitialLocationsLoad) { + if (isPending || profileLoading) { return (
@@ -128,8 +243,6 @@ export default function DashboardLocationsPage() { return null; } - const locations = locationsData?.locations ?? []; - return ( } > - {/* Filters */} -
-
- {/* Search */} -
-
- - setSearchInput(e.target.value)} - placeholder="Standort suchen..." - className="dark:border-dark-border dark:bg-dark-background dark:text-dark-text w-full rounded-lg border border-gray-300 py-2 pr-4 pl-10 focus:border-transparent focus:ring-2 focus:ring-blue-500" - /> -
-
-
-
- - {/* Locations List */} -
- {locations.length === 0 ? ( -
-
- -
-

+ location.id} + isLoading={locationsLoading} + rowNoun={["Standort", "Standorte"]} + searchPlaceholder="Standort suchen…" + initialSorting={[{ id: "city", desc: false }]} + emptyState={ + <> + +

Keine Standorte gefunden

- {search - ? "Keine Standorte entsprechen deinen Filterkriterien." - : "Erstelle den ersten Standort, um ihn hier anzuzeigen."} + Erstelle den ersten Standort, um ihn hier anzuzeigen.

- {!search && ( - - - Standort erstellen - - )} -

- ) : ( -
-
- - - - - - - - - - - {locations.map((location) => ( - - - - - - - ))} - -
- Standort - - Adresse - - Verwendung - - Aktionen -
-
- - {location.name || "Unbenannter Standort"} - - {location.additionalInfo && ( -

- {location.additionalInfo} -

- )} -
-
-
- {location.street && ( - - {location.street} - - )} - {location.street && - (location.zipCode || location.city) &&
} - {(location.zipCode || location.city) && ( - - {location.zipCode && `${location.zipCode} `} - {location.city} - - )} - {!location.street && - !location.zipCode && - !location.city && ( - - Keine Adresse - - )} -
-
-
- {location._count.events > 0 && ( - - {location._count.events} Termin - {location._count.events !== 1 && "e"} - - )} - {location._count.courses > 0 && ( - - {location._count.courses} Kurs - {location._count.courses !== 1 && "e"} - - )} - {location._count.ensembles > 0 && ( - - {location._count.ensembles} Ensemble - {location._count.ensembles !== 1 && "s"} - - )} - {location._count.events === 0 && - location._count.courses === 0 && - location._count.ensembles === 0 && ( - - Nicht verwendet - - )} -
-
-
- - - - - - - -
-
-
- {/* Pagination & Results count */} -
-
- {locationsData?.total} Standort - {locationsData?.total !== 1 && "e"} gefunden - {locationsData && locationsData.pages > 1 && ( - - {" "} - · Seite {page} von {locationsData.pages} - - )} -
- {locationsData && locationsData.pages > 1 && ( -
- -
- {Array.from( - { length: Math.min(5, locationsData.pages) }, - (_, i) => { - let pageNum: number; - if (locationsData.pages <= 5) { - pageNum = i + 1; - } else if (page <= 3) { - pageNum = i + 1; - } else if (page >= locationsData.pages - 2) { - pageNum = locationsData.pages - 4 + i; - } else { - pageNum = page - 2 + i; - } - return ( - - ); - }, - )} -
- -
- )} -
-
- )} -
+ + + Standort erstellen + + + } + />
); } diff --git a/src/app/dashboard/newsletter/subscribers/page.tsx b/src/app/dashboard/newsletter/subscribers/page.tsx index 7cad8373..0977513f 100644 --- a/src/app/dashboard/newsletter/subscribers/page.tsx +++ b/src/app/dashboard/newsletter/subscribers/page.tsx @@ -2,23 +2,72 @@ import { useSession } from "@/lib/auth"; import { redirect } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; -import { api } from "@/trpc/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { api, type RouterOutputs } from "@/trpc/react"; import { usePermissions } from "@/lib/use-permissions"; import { PERMISSIONS } from "@/lib/permissions"; import Link from "next/link"; import { DashboardPage } from "@/app/_components/dashboard"; +import { + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; +import type { + ColumnFiltersState, + PaginationState, + SortingState, +} from "@tanstack/react-table"; import { useToast } from "@/app/_components/ui/toast"; import { Mail } from "lucide-react"; +type Subscriber = + RouterOutputs["newsletter"]["getSubscribers"]["subscribers"][number]; + +/** The columns the server can sort by. */ +const SORTABLE_COLUMNS = { + email: "email", + name: "name", + subscribedAt: "subscribedAt", +} as const; + +type SortableColumn = keyof typeof SORTABLE_COLUMNS; + +const STATUS_OPTIONS = [ + { value: "confirmed", label: "Bestätigt" }, + { value: "pending", label: "Ausstehend" }, + { value: "inactive", label: "Inaktiv" }, +]; + +const column = createDataTableColumnHelper(); + +/** Der Status, den die Statusspalte zeigt — passend zum Serverfilter benannt. */ +function subscriberStatus(subscriber: Subscriber): string { + if (!subscriber.isActive) return "inactive"; + return subscriber.confirmedAt ? "confirmed" : "pending"; +} + +/** Reads one set filter out of the table's filter state. */ +function setFilterValues(filters: ColumnFiltersState, id: string): string[] { + const value = filters.find((filter) => filter.id === id)?.value; + return Array.isArray(value) ? (value as string[]) : []; +} + export default function DashboardNewsletterSubscribersPage() { const { data: session, isPending } = useSession(); const hasRedirected = useRef(false); - const [page, setPage] = useState(1); + // Die Abonnentenliste wächst unbegrenzt und wird deshalb serverseitig + // geblättert; Sortierung, Statusfilter und Suche sind darum Abfrageparameter + // — sonst würden sie nur die gerade geladene Seite betreffen. + const [sorting, setSorting] = useState([ + { id: "subscribedAt", desc: true }, + ]); + const [columnFilters, setColumnFilters] = useState([]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 50, + }); const [search, setSearch] = useState(""); - const [isActiveFilter, setIsActiveFilter] = useState( - true, - ); const { data: profile, isLoading: profileLoading } = api.users.getMyProfile.useQuery(undefined, { @@ -28,13 +77,22 @@ export default function DashboardNewsletterSubscribersPage() { const { hasPermission, isLoading: permissionsLoading } = usePermissions(); const canManageNewsletter = hasPermission(PERMISSIONS.NEWSLETTER_MANAGE); + const statusFilter = setFilterValues(columnFilters, "status"); + const { data: subscribersData, isLoading: subscribersLoading } = api.newsletter.getSubscribers.useQuery( { - page, - limit: 50, - isActive: isActiveFilter, + page: pagination.pageIndex + 1, + limit: pagination.pageSize, + status: statusFilter.length + ? (statusFilter as ("confirmed" | "pending" | "inactive")[]) + : undefined, search: search || undefined, + sortBy: + SORTABLE_COLUMNS[ + (sorting[0]?.id ?? "subscribedAt") as SortableColumn + ], + sortOrder: sorting[0]?.desc === false ? "asc" : "desc", }, { enabled: !!session?.user && !!profile, @@ -84,6 +142,84 @@ export default function DashboardNewsletterSubscribersPage() { } }, [profile, profileLoading, permissionsLoading, canManageNewsletter]); + const columns = useMemo[]>( + () => + column.columns([ + column.accessor((subscriber) => subscriber.email, { + id: "email", + header: "E-Mail", + enableColumnFilter: false, + meta: { alwaysVisible: true, cellClassName: "whitespace-nowrap" }, + }), + column.accessor((subscriber) => subscriber.name ?? "", { + id: "name", + header: "Name", + enableColumnFilter: false, + cell: ({ getValue }) => getValue() || "-", + }), + column.accessor(subscriberStatus, { + id: "status", + header: "Status", + enableSorting: false, + meta: { filterVariant: "set", filterOptions: STATUS_OPTIONS }, + cell: ({ row }) => { + const subscriber = row.original; + if (subscriber.isActive && subscriber.confirmedAt) { + return ( + + Bestätigt + + ); + } + if (subscriber.isActive) { + return ( + + Ausstehend + + ); + } + return ( + + Inaktiv + + ); + }, + }), + column.accessor((subscriber) => subscriber.subscribedAt, { + id: "subscribedAt", + header: "Abonniert am", + enableColumnFilter: false, + meta: { cellClassName: "whitespace-nowrap" }, + cell: ({ getValue }) => + new Date(getValue()).toLocaleDateString("de-DE"), + }), + column.display({ + id: "actions", + header: "Aktionen", + meta: { align: "right", label: "Aktionen" }, + cell: ({ row }) => ( + + ), + }), + ]), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + if (isPending || profileLoading) { return (
@@ -152,167 +288,38 @@ export default function DashboardNewsletterSubscribersPage() { )} {/* Filters */} -
-
- { - setSearch(e.target.value); - setPage(1); - }} - className="dark:bg-dark-surface dark:border-dark-border dark:text-dark-text focus:border-primary focus:ring-primary/20 w-full rounded-lg border border-gray-300 px-4 py-2 focus:ring-2 focus:outline-none" - /> -
-
- - - -
-
- - {/* Subscribers List */} - {subscribersLoading ? ( -
-
-
- ) : subscribersData && subscribersData.subscribers.length > 0 ? ( - <> -
- - - - - - - - - - - - {subscribersData.subscribers.map((subscriber) => ( - - - - - - - - ))} - -
- E-Mail - - Name - - Status - - Abonniert am - - Aktionen -
- {subscriber.email} - - {subscriber.name || "-"} - - {subscriber.isActive && subscriber.confirmedAt ? ( - - Bestätigt - - ) : subscriber.isActive ? ( - - Ausstehend - - ) : ( - - Inaktiv - - )} - - {new Date(subscriber.subscribedAt).toLocaleDateString( - "de-DE", - )} - - -
-
- - {/* Pagination */} - {subscribersData.pages > 1 && ( -
- - - Seite {page} von {subscribersData.pages} - - -
- )} - - ) : ( -
-

+ subscriber.id} + isLoading={subscribersLoading} + rowNoun={["Abonnent", "Abonnenten"]} + searchPlaceholder="Suche nach E-Mail oder Name…" + pageSizeOptions={[50, 100, 250]} + emptyState={ + Keine Abonnenten gefunden. -

-
- )} + + } + sorting={sorting} + onSortingChange={setSorting} + manualSorting + columnFilters={columnFilters} + onColumnFiltersChange={(updater) => { + setColumnFilters(updater); + setPagination((current) => ({ ...current, pageIndex: 0 })); + }} + manualFiltering + search={search} + onSearchChange={(value) => { + setSearch(value); + setPagination((current) => ({ ...current, pageIndex: 0 })); + }} + pagination={pagination} + onPaginationChange={setPagination} + manualPagination + rowCount={subscribersData?.total ?? 0} + /> ); } diff --git a/src/app/dashboard/permissions/page.tsx b/src/app/dashboard/permissions/page.tsx index e6e97263..255a1ac1 100644 --- a/src/app/dashboard/permissions/page.tsx +++ b/src/app/dashboard/permissions/page.tsx @@ -3,8 +3,13 @@ import { useSession } from "@/lib/auth"; import { redirect } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; -import { api } from "@/trpc/react"; +import { api, type RouterOutputs } from "@/trpc/react"; import { DashboardPage } from "@/app/_components/dashboard"; +import { + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; import { Shield, Users, @@ -29,6 +34,10 @@ import { type Tab = "roles" | "users"; +type ManagedRole = RouterOutputs["permissions"]["getAllRoles"][number]; + +const roleColumn = createDataTableColumnHelper(); + export default function PermissionsPage() { const { data: session, isPending } = useSession(); const hasRedirected = useRef(false); @@ -188,13 +197,104 @@ function RolesTab() { }); }; - if (isLoading) { - return ( -
-
-
- ); - } + const roleColumns = useMemo[]>( + () => + roleColumn.columns([ + roleColumn.accessor((role) => role.name, { + id: "name", + header: "Name", + meta: { alwaysVisible: true, cellClassName: "font-medium" }, + cell: ({ row }) => ( + <> + {row.original.name} + {row.original.isSystem && ( + (System) + )} + + ), + }), + roleColumn.accessor((role) => role.description ?? "", { + id: "description", + header: "Beschreibung", + cell: ({ getValue }) => getValue() || "-", + }), + roleColumn.accessor((role) => (role.isSystem ? "System" : "Eigene"), { + id: "kind", + header: "Art", + meta: { filterVariant: "set", label: "Art" }, + }), + roleColumn.accessor((role) => role.permissions.length, { + id: "permissionCount", + header: "Berechtigungen", + meta: { align: "right", filterVariant: "number" }, + cell: ({ getValue }) => + `${getValue()} Berechtigung${getValue() === 1 ? "" : "en"}`, + }), + roleColumn.display({ + id: "actions", + header: "Aktionen", + meta: { align: "right", label: "Aktionen" }, + cell: ({ row }) => { + const role = row.original; + if (role.isSystem && isAdminRole(role.name)) { + return Admin; + } + if (editingId === role.id) { + return ( +
+ + +
+ ); + } + return ( +
+ + {!role.isSystem && ( + + )} +
+ ); + }, + }), + ]), + // eslint-disable-next-line react-hooks/exhaustive-deps + [editingId], + ); return (
@@ -215,113 +315,23 @@ function RolesTab() {
- {roles && roles.length > 0 ? ( -
- - - - - - - - - - - {roles.map((role) => ( - - - - - - - ))} - -
- Name - - Beschreibung - - Berechtigungen - - Aktionen -
- {role.name} - {role.isSystem && ( - - (System) - - )} - - {role.description || "-"} - - {role.permissions.length} Berechtigung - {role.permissions.length !== 1 ? "en" : ""} - - {role.isSystem && isAdminRole(role.name) ? ( - Admin - ) : editingId === role.id ? ( -
- - -
- ) : ( -
- - {!role.isSystem && ( - - )} -
- )} -
-
- ) : ( -
- -

- Noch keine Rollen vorhanden -

-
- )} + role.id} + isLoading={isLoading} + rowNoun={["Rolle", "Rollen"]} + searchPlaceholder="Rolle oder Beschreibung suchen…" + initialSorting={[{ id: "name", desc: false }]} + emptyState={ + <> + +

+ Noch keine Rollen vorhanden +

+ + } + /> {/* Create/Edit Modal */} {(showCreateModal || editingId) && ( diff --git a/src/app/dashboard/posaunenrat/page.tsx b/src/app/dashboard/posaunenrat/page.tsx index 69ff3016..d36a7623 100644 --- a/src/app/dashboard/posaunenrat/page.tsx +++ b/src/app/dashboard/posaunenrat/page.tsx @@ -1,16 +1,21 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useSession } from "@/lib/auth"; import { useToast } from "@/app/_components/ui/toast"; import { useRouter } from "next/navigation"; import { useEffect, useRef } from "react"; -import { api } from "@/trpc/react"; +import { api, type RouterOutputs } from "@/trpc/react"; import { usePermissions } from "@/lib/use-permissions"; import { PERMISSIONS } from "@/lib/permissions"; import Link from "next/link"; import Image from "next/image"; import { DashboardPage } from "@/app/_components/dashboard"; +import { + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; import { ChevronDownIcon, ChevronUpIcon, @@ -28,6 +33,20 @@ const POSAUNENRAT_ROLE_LABELS: Record = { SACHVERSTAENDIGE: "Sachverständige", }; +type PosaunenratMember = + RouterOutputs["organization"]["getPosaunenrat"][number]; + +const column = createDataTableColumnHelper(); + +/** Der verknüpfte Benutzername schlägt den frei eingetragenen. */ +function memberName(member: PosaunenratMember): string { + return member.user?.displayName ?? member.name ?? "Unbekannt"; +} + +function memberEmail(member: PosaunenratMember): string { + return member.user?.email ?? member.email ?? "-"; +} + export default function DashboardPosaunenratPage() { const router = useRouter(); const { data: session, isPending } = useSession(); @@ -124,6 +143,153 @@ export default function DashboardPosaunenratPage() { } }; + const columns = useMemo[]>( + () => + column.columns([ + // Die gespeicherte Reihenfolge als eigene Spalte: nur so bleiben die + // Hoch/Runter-Pfeile nachvollziehbar, wenn nach etwas anderem sortiert + // wird — sie verschieben immer die gespeicherte Position, nie die Sicht. + column.accessor((member) => (members?.indexOf(member) ?? 0) + 1, { + id: "position", + header: "#", + enableColumnFilter: false, + meta: { align: "right", label: "Reihenfolge" }, + }), + column.accessor(memberName, { + id: "member", + header: "Mitglied", + meta: { alwaysVisible: true }, + cell: ({ row }) => { + const member = row.original; + const displayName = memberName(member); + const imageUrl = + member.user?.profileImage?.url ?? member.image?.url; + return ( +
+ {imageUrl ? ( +
+ {displayName} +
+ ) : ( +
+ + {displayName.charAt(0).toUpperCase()} + +
+ )} +
+ + {displayName} + +

+ {memberEmail(member)} +

+
+
+ ); + }, + }), + column.accessor( + (member) => POSAUNENRAT_ROLE_LABELS[member.role] ?? member.role, + { + id: "role", + header: "Rolle", + meta: { filterVariant: "set" }, + cell: ({ getValue }) => ( + + {getValue()} + + ), + }, + ), + column.accessor( + (member) => + member.user ? "Benutzer verknüpft" : "Manueller Eintrag", + { + id: "linked", + header: "Verknüpfung", + meta: { filterVariant: "set" }, + cell: ({ row }) => + row.original.user ? ( + + Benutzer verknüpft + + ) : ( + + Manueller Eintrag + + ), + }, + ), + column.display({ + id: "actions", + header: "Aktionen", + meta: { align: "right", label: "Aktionen" }, + cell: ({ row }) => { + const index = members?.indexOf(row.original) ?? -1; + return ( +
+ + + + + + +
+ ); + }, + }), + ]), + // eslint-disable-next-line react-hooks/exhaustive-deps + [members, deletingId, isReordering], + ); + if (isPending || profileLoading || membersLoading) { return (
@@ -154,160 +320,32 @@ export default function DashboardPosaunenratPage() { } > - {/* Members List */} - {!members || members.length === 0 ? ( -
-
- -
-

- Keine Posaunenratsmitglieder -

-

- Es wurden noch keine Posaunenratsmitglieder angelegt. -

- - Erstes Mitglied anlegen - -
- ) : ( -
-
- - - - - - - - - - - {members.map((member, index) => { - const displayName = - member.user?.displayName || member.name || "Unbekannt"; - const displayEmail = - member.user?.email || member.email || "-"; - const imageUrl = - member.user?.profileImage?.url || member.image?.url; - - return ( - - - - - - - ); - })} - -
- Mitglied - - Rolle - - Verknüpfung - - Aktionen -
-
- {imageUrl ? ( -
- {displayName} -
- ) : ( -
- - {displayName.charAt(0).toUpperCase()} - -
- )} -
- - {displayName} - -

- {displayEmail} -

-
-
-
- - {POSAUNENRAT_ROLE_LABELS[member.role] || member.role} - - - {member.user ? ( - - Benutzer verknüpft - - ) : ( - - Manueller Eintrag - - )} - -
- - - - - - -
-
-
-
- )} + member.id} + isLoading={membersLoading} + rowNoun={["Mitglied", "Mitglieder"]} + searchPlaceholder="Name, E-Mail oder Rolle suchen…" + initialSorting={[{ id: "position", desc: false }]} + emptyState={ + <> + +

+ Keine Posaunenratsmitglieder +

+

+ Es wurden noch keine Posaunenratsmitglieder angelegt. +

+ + Erstes Mitglied anlegen + + + } + /> ); } diff --git a/src/app/dashboard/posaunenwarte/page.tsx b/src/app/dashboard/posaunenwarte/page.tsx index 612533a7..e9e1f1ae 100644 --- a/src/app/dashboard/posaunenwarte/page.tsx +++ b/src/app/dashboard/posaunenwarte/page.tsx @@ -3,13 +3,18 @@ import { useSession } from "@/lib/auth"; import { useToast } from "@/app/_components/ui/toast"; import { useRouter } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; -import { api } from "@/trpc/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { api, type RouterOutputs } from "@/trpc/react"; import { usePermissions } from "@/lib/use-permissions"; import { PERMISSIONS } from "@/lib/permissions"; import Link from "next/link"; import Image from "next/image"; import { DashboardPage } from "@/app/_components/dashboard"; +import { + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; import { ChevronDownIcon, ChevronUpIcon, @@ -25,6 +30,10 @@ const ROLE_LABELS: Record = { RPW: "Regionalposaunenwart", }; +type Posaunenwart = RouterOutputs["organization"]["getPosaunenwarte"][number]; + +const column = createDataTableColumnHelper(); + export default function DashboardPosaunenwartenPage() { const router = useRouter(); const { data: session, isPending } = useSession(); @@ -105,6 +114,180 @@ export default function DashboardPosaunenwartenPage() { router, ]); + const columns = useMemo[]>( + () => + column.columns([ + // Die gespeicherte Reihenfolge als eigene Spalte: nur so bleiben die + // Hoch/Runter-Pfeile nachvollziehbar, wenn nach etwas anderem sortiert + // wird — sie verschieben immer die gespeicherte Position, nie die Sicht. + column.accessor((person) => (posaunenwarte?.indexOf(person) ?? 0) + 1, { + id: "position", + header: "#", + enableColumnFilter: false, + meta: { align: "right", label: "Reihenfolge" }, + }), + column.accessor((person) => person.name ?? "Unbekannt", { + id: "person", + header: "Posaunenwart", + meta: { alwaysVisible: true }, + cell: ({ row }) => { + const person = row.original; + return ( +
+ {person.profileImage?.url ? ( +
+ {person.name +
+ ) : ( +
+ +
+ )} +
+ + {person.name ?? "Unbekannt"} + +

+ {person.email} +

+
+
+ ); + }, + }), + column.accessor( + (person) => + person.role + ? (ROLE_LABELS[person.role] ?? person.role) + : "Unbekannt", + { + id: "role", + header: "Rolle", + meta: { filterVariant: "set" }, + cell: ({ row, getValue }) => ( + <> + + {getValue()} + + {row.original.districtRoleName && ( +

+ {row.original.districtRoleName} +

+ )} + + ), + }, + ), + column.accessor( + (person) => + (person.bezirke ?? []) + .map((bezirk) => `Bezirk ${bezirk.number}`) + .join(", "), + { + id: "bezirke", + header: "Zuständige Bezirke", + cell: ({ row }) => { + const person = row.original; + if (person.bezirke && person.bezirke.length > 0) { + return ( +
+ {person.bezirke.map((bezirk) => ( + + Bezirk {bezirk.number} + + ))} +
+ ); + } + return ( + + {person.role === "LPW" ? "Alle Bezirke" : "Keine Zuordnung"} + + ); + }, + }, + ), + column.display({ + id: "actions", + header: "Aktionen", + meta: { align: "right", label: "Aktionen" }, + cell: ({ row }) => { + const person = row.original; + const index = posaunenwarte?.indexOf(person) ?? -1; + const list = posaunenwarte ?? []; + return ( +
+ + + + + + + + + {person.userId && ( + + + + )} +
+ ); + }, + }), + ]), + // eslint-disable-next-line react-hooks/exhaustive-deps + [posaunenwarte, isReordering], + ); + if (isPending || profileLoading || posaunenwarteLoading) { return (
@@ -137,184 +320,32 @@ export default function DashboardPosaunenwartenPage() {
} > - {/* Posaunenwarte List */} - {!posaunenwarte || posaunenwarte.length === 0 ? ( -
-
- -
-

- Keine Posaunenwarte -

-

- Es wurden noch keine Posaunenwarte angelegt. -

- - Ersten Posaunenwart anlegen - -
- ) : ( -
-
- - - - - - - - - - - {posaunenwarte.map((person, index) => ( - - - - - - - ))} - -
- Posaunenwart - - Rolle - - Zuständige Bezirke - - Aktionen -
-
- {person.profileImage?.url ? ( -
- {person.name -
- ) : ( -
- -
- )} -
- - {person.name || "Unbekannt"} - -

- {person.email} -

-
-
-
- - {person.role - ? ROLE_LABELS[person.role] || person.role - : "Unbekannt"} - - {person.districtRoleName && ( -

- {person.districtRoleName} -

- )} -
- {person.bezirke && person.bezirke.length > 0 ? ( -
- {person.bezirke.map( - (bezirk: { - id: string; - number: number; - name: string | null; - }) => ( - - Bezirk {bezirk.number} - - ), - )} -
- ) : ( - - {person.role === "LPW" - ? "Alle Bezirke" - : person.role === "RPW" - ? `${person.bezirke?.length || 0} Bezirk(e)` - : "Keine Zuordnung"} - - )} -
-
- - - - - - - - - {person.userId && ( - - - - )} -
-
-
-
- )} + person.id} + isLoading={posaunenwarteLoading} + rowNoun={["Posaunenwart:in", "Posaunenwarte"]} + searchPlaceholder="Name, E-Mail oder Bezirk suchen…" + initialSorting={[{ id: "position", desc: false }]} + emptyState={ + <> + +

+ Keine Posaunenwarte +

+

+ Es wurden noch keine Posaunenwarte angelegt. +

+ + Ersten Posaunenwart anlegen + + + } + /> ); } diff --git a/src/app/dashboard/registrations/page.tsx b/src/app/dashboard/registrations/page.tsx index d1a9d5fb..9a16646f 100644 --- a/src/app/dashboard/registrations/page.tsx +++ b/src/app/dashboard/registrations/page.tsx @@ -1,10 +1,15 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; -import { api } from "@/trpc/react"; +import { api, type RouterOutputs } from "@/trpc/react"; import { DashboardPage } from "@/app/_components/dashboard"; +import { + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; import { usePermissions } from "@/lib/use-permissions"; import { PERMISSIONS } from "@/lib/permissions"; import { @@ -12,8 +17,17 @@ import { SiblingDiscountStatus, } from "~/generated/prisma/enums"; import { RegistrationPaymentBadge } from "@/app/_components/dashboard/invoice-payment-badge"; +import { registrationPaymentState } from "@/lib/invoice-payment"; +import type { + ColumnFiltersState, + PaginationState, + SortingState, +} from "@tanstack/react-table"; import { PencilIcon, SearchIcon, UsersIcon } from "lucide-react"; +type AdminRegistration = + RouterOutputs["registrations"]["getAllAdmin"]["registrations"][number]; + const REGISTRATION_STATUS_LABELS: Record = { CONFIRMED: "Bestätigt", WAITLIST: "Warteliste", @@ -28,6 +42,30 @@ const REGISTRATION_STATUS_BADGES: Record = { CANCELLED: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300", }; +const DISCOUNT_OPTIONS = [ + { value: SiblingDiscountStatus.PENDING, label: "Wartet auf Freigabe" }, + { value: SiblingDiscountStatus.APPROVED, label: "Genehmigt" }, + { value: SiblingDiscountStatus.REJECTED, label: "Abgelehnt" }, +]; + +const PAYMENT_OPTIONS = [ + { value: "open", label: "Offen" }, + { value: "paid", label: "Bezahlt" }, +]; + +/** The columns the server can sort by. */ +const SORTABLE_COLUMNS = { + registrant: "registrant", + course: "course", + totalPrice: "totalPrice", + status: "status", + createdAt: "createdAt", +} as const; + +type SortableColumn = keyof typeof SORTABLE_COLUMNS; + +const column = createDataTableColumnHelper(); + function formatPrice(price: number) { return new Intl.NumberFormat("de-DE", { style: "currency", @@ -43,18 +81,15 @@ function formatDate(date: Date | string) { }).format(new Date(date)); } +/** Reads one set filter out of the table's filter state. */ +function setFilterValues(filters: ColumnFiltersState, id: string): string[] { + const value = filters.find((filter) => filter.id === id)?.value; + return Array.isArray(value) ? (value as string[]) : []; +} + export default function AdminRegistrationsPage() { const { hasPermission, isLoading: permissionsLoading } = usePermissions(); - const [page, setPage] = useState(1); - const [search, setSearch] = useState(""); - const [searchInput, setSearchInput] = useState(""); - const [registrationStatus, setRegistrationStatus] = useState< - RegistrationStatus | "" - >(""); - /** "" = alle, "paid" = alles beglichen, "open" = mindestens eine offene Rechnung. */ - const [paymentFilter, setPaymentFilter] = useState<"" | "paid" | "open">(""); - const [courseId, setCourseId] = useState(""); const canViewAll = hasPermission(PERMISSIONS.COURSES_MANAGE_REGISTRATIONS); /** * Wer nur den Geschwisterkindrabatt verwaltet, sieht hier ausschließlich @@ -70,35 +105,61 @@ export default function AdminRegistrationsPage() { // Vorbelegt über ?discount=PENDING — so landet die Freigabe-Kachel des // Dashboards direkt auf den offenen Rabatten statt auf der vollen Liste. const searchParams = useSearchParams(); - const [discountFilter, setDiscountFilter] = useState< - SiblingDiscountStatus | "" - >(() => { + const [columnFilters, setColumnFilters] = useState(() => { const requested = searchParams.get("discount"); // NONE ist kein Rabattstatus, den man hier prüfen würde, und steht auch im // Filter nicht zur Wahl — aus der URL wird er deshalb nicht übernommen. - if ( + const initial = requested && requested !== SiblingDiscountStatus.NONE && Object.values(SiblingDiscountStatus).includes( requested as SiblingDiscountStatus, ) - ) { - return requested as SiblingDiscountStatus; - } - return discountOnly ? SiblingDiscountStatus.PENDING : ""; + ? requested + : discountOnly + ? SiblingDiscountStatus.PENDING + : null; + return initial ? [{ id: "discount", value: [initial] }] : []; }); + // Die Liste geht über alle Kurse und wird serverseitig geblättert; Sortierung, + // Spaltenfilter und Suche sind darum Abfrageparameter — sonst würden sie nur + // die gerade geladene Seite betreffen. + const [sorting, setSorting] = useState([ + { id: "createdAt", desc: true }, + ]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 25, + }); + const [search, setSearch] = useState(""); + + const courseFilter = setFilterValues(columnFilters, "course"); + const statusFilter = setFilterValues(columnFilters, "status"); + const paymentFilter = setFilterValues(columnFilters, "payment"); + const discountFilter = setFilterValues(columnFilters, "discount"); + const { data, isLoading } = api.registrations.getAllAdmin.useQuery( { - page, - limit: 25, + page: pagination.pageIndex + 1, + limit: pagination.pageSize, search: search || undefined, - registrationStatus: registrationStatus || undefined, - paid: paymentFilter === "" ? undefined : paymentFilter === "paid", - siblingDiscountStatus: discountFilter || undefined, - courseId: courseId || undefined, + registrationStatus: statusFilter.length + ? (statusFilter as RegistrationStatus[]) + : undefined, + // Nur eindeutig: "offen" und "bezahlt" zugleich ist dasselbe wie kein + // Filter, denn der Server kennt hier nur ein Ja/Nein. + paid: + paymentFilter.length === 1 ? paymentFilter[0] === "paid" : undefined, + siblingDiscountStatus: discountFilter.length + ? (discountFilter as SiblingDiscountStatus[]) + : undefined, + courseId: courseFilter.length ? courseFilter : undefined, + sortBy: + SORTABLE_COLUMNS[(sorting[0]?.id ?? "createdAt") as SortableColumn], + sortOrder: sorting[0]?.desc === false ? "asc" : "desc", }, - { enabled: canView && (canViewAll || !!discountFilter) }, + { enabled: canView && (canViewAll || discountFilter.length > 0) }, ); const { data: courses } = @@ -106,11 +167,180 @@ export default function AdminRegistrationsPage() { enabled: canViewAll, }); - const applySearch = (e: React.FormEvent) => { - e.preventDefault(); - setSearch(searchInput.trim()); - setPage(1); - }; + const columns = useMemo[]>( + () => + column.columns([ + column.accessor( + (registration) => + `${registration.registrantFirstName} ${registration.registrantLastName}`, + { + id: "registrant", + header: "Anmelder:in", + enableColumnFilter: false, + meta: { alwaysVisible: true }, + cell: ({ row }) => ( + <> + + {row.original.registrantFirstName}{" "} + {row.original.registrantLastName} + +

+ {row.original.registrantEmail} +

+ + ), + }, + ), + column.accessor((registration) => registration.course.id, { + id: "course", + header: "Kurs", + meta: { + filterVariant: "set", + label: "Kurs", + filterOptions: (courses ?? []).map((course) => ({ + value: course.id, + label: `${course.title} (${formatDate(course.startDate)})`, + })), + }, + cell: ({ row }) => ( + <> + + {row.original.course.title} + +

+ {formatDate(row.original.course.startDate)} +

+ + ), + }), + column.accessor((registration) => registration._count.participants, { + id: "participants", + header: "Teiln.", + enableSorting: false, + enableColumnFilter: false, + meta: { + align: "right", + label: "Teilnehmerzahl", + cellClassName: "tabular-nums", + }, + }), + column.accessor((registration) => registration.registrationStatus, { + id: "status", + header: "Status", + meta: { + filterVariant: "set", + filterOptions: Object.entries(REGISTRATION_STATUS_LABELS).map( + ([value, label]) => ({ value, label }), + ), + }, + cell: ({ row }) => ( + + {REGISTRATION_STATUS_LABELS[row.original.registrationStatus]} + + ), + }), + column.accessor( + (registration) => + registrationPaymentState(registration.invoices) === "PAID" + ? "paid" + : "open", + { + id: "payment", + header: "Zahlung", + enableSorting: false, + meta: { filterVariant: "set", filterOptions: PAYMENT_OPTIONS }, + cell: ({ row }) => ( + + ), + }, + ), + column.accessor((registration) => registration.totalPrice, { + id: "totalPrice", + header: "Betrag", + enableColumnFilter: false, + meta: { align: "right", cellClassName: "tabular-nums" }, + cell: ({ row }) => ( + <> + {formatPrice(row.original.totalPrice)} + {row.original.siblingDiscountStatus === + SiblingDiscountStatus.PENDING && ( + + Rabatt prüfen + {row.original.siblingDiscountAmount + ? ` (${formatPrice(row.original.siblingDiscountAmount)})` + : ""} + + )} + + ), + }), + column.accessor((registration) => registration.siblingDiscountStatus, { + id: "discount", + header: "Rabatt", + enableSorting: false, + meta: { + filterVariant: "set", + label: "Geschwisterrabatt", + filterOptions: DISCOUNT_OPTIONS, + }, + cell: ({ getValue }) => + DISCOUNT_OPTIONS.find((option) => option.value === getValue()) + ?.label ?? "–", + }), + column.accessor((registration) => registration.invoiceId ?? "", { + id: "invoice", + header: "Rechnung", + enableSorting: false, + enableColumnFilter: false, + cell: ({ getValue }) => getValue() || "–", + }), + column.accessor((registration) => registration.createdAt, { + id: "createdAt", + header: "Datum", + enableColumnFilter: false, + meta: { cellClassName: "whitespace-nowrap" }, + cell: ({ getValue }) => formatDate(getValue()), + }), + column.display({ + id: "actions", + header: "Aktionen", + meta: { align: "right", label: "Aktionen" }, + cell: ({ row }) => + // Wer nur den Rabatt prüft, darf die Anmeldung nicht zwangsläufig + // bearbeiten — er wird auf die Anmeldungsseite geschickt, wo + // genehmigen und ablehnen sitzen. + discountOnly ? ( + + + Rabatt prüfen + + ) : row.original.registrationStatus !== + RegistrationStatus.CANCELLED ? ( + + + Bearbeiten + + ) : null, + }), + ]), + [courses, discountOnly], + ); if (!permissionsLoading && !canView) { return ( @@ -131,304 +361,46 @@ export default function AdminRegistrationsPage() { : "Alle Kursanmeldungen kursübergreifend durchsuchen und filtern" } > -
-
-
- -
- setSearchInput(e.target.value)} - placeholder="Name, E-Mail, Teilnehmer oder Rechnungsnummer…" - className="dark:bg-dark-background dark:border-dark-border dark:text-dark-text w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" - /> - -
-
- - {!discountOnly && ( -
- - -
- )} - -
- - -
- -
- - -
- -
- - -
+ {discountOnly && discountFilter.length === 0 && ( +
+ Wähle im Spaltenfilter „Rabatt“ einen Status aus — deine Berechtigung + gilt nur für Anmeldungen mit Geschwisterkindrabatt.
-
+ )} -
- {isLoading ? ( -
-
-
- ) : !data || data.registrations.length === 0 ? ( -
+ registration.id} + isLoading={isLoading} + rowNoun={["Anmeldung", "Anmeldungen"]} + searchPlaceholder="Name, E-Mail, Teilnehmer oder Rechnungsnummer…" + pageSizeOptions={[25, 50, 100, 250]} + emptyState={ + -

Keine Anmeldungen gefunden.

-
- ) : ( - <> -
- - - - {[ - "Anmelder:in", - "Kurs", - "Teiln.", - "Status", - "Zahlung", - "Betrag", - "Rechnung", - "Datum", - "", - ].map((header, index) => ( - - ))} - - - - {data.registrations.map((registration) => ( - - - - - - - - - - - - ))} - -
- {header} -
- - {registration.registrantFirstName}{" "} - {registration.registrantLastName} - -

- {registration.registrantEmail} -

-
- - {registration.course.title} - -

- {formatDate(registration.course.startDate)} -

-
- {registration._count.participants} - - - { - REGISTRATION_STATUS_LABELS[ - registration.registrationStatus - ] - } - - - - - {formatPrice(registration.totalPrice)} - {registration.siblingDiscountStatus === - SiblingDiscountStatus.PENDING && ( - - Rabatt prüfen - {registration.siblingDiscountAmount - ? ` (${formatPrice(registration.siblingDiscountAmount)})` - : ""} - - )} - - {registration.invoiceId ?? "–"} - - {formatDate(registration.createdAt)} - - {/* Wer nur den Rabatt prüft, darf die Anmeldung nicht - zwangsläufig bearbeiten — er wird auf die - Anmeldungsseite geschickt, wo genehmigen und - ablehnen sitzen. */} - {discountOnly ? ( - - - Rabatt prüfen - - ) : ( - registration.registrationStatus !== - RegistrationStatus.CANCELLED && ( - - - Bearbeiten - - ) - )} -
-
- -
-

- {data.total} {data.total === 1 ? "Anmeldung" : "Anmeldungen"} -

- {data.pages > 1 && ( -
- - - Seite {page} von {data.pages} - - -
- )} -
- - )} -
+ Keine Anmeldungen gefunden. + + } + sorting={sorting} + onSortingChange={setSorting} + manualSorting + columnFilters={columnFilters} + onColumnFiltersChange={(updater) => { + setColumnFilters(updater); + setPagination((current) => ({ ...current, pageIndex: 0 })); + }} + manualFiltering + search={search} + onSearchChange={(value) => { + setSearch(value); + setPagination((current) => ({ ...current, pageIndex: 0 })); + }} + pagination={pagination} + onPaginationChange={setPagination} + manualPagination + rowCount={data?.total ?? 0} + /> ); } diff --git a/src/app/dashboard/stats/page.tsx b/src/app/dashboard/stats/page.tsx index d902adbf..a0fa35ab 100644 --- a/src/app/dashboard/stats/page.tsx +++ b/src/app/dashboard/stats/page.tsx @@ -2,10 +2,15 @@ import { useSession } from "@/lib/auth"; import { redirect } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; -import { api } from "@/trpc/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { api, type RouterOutputs } from "@/trpc/react"; import Link from "next/link"; import { DashboardPage } from "@/app/_components/dashboard"; +import { + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; import { BarChart3, FileText, @@ -33,6 +38,12 @@ const DailyViewsChart = dynamic( }, ); +type PathRow = RouterOutputs["stats"]["getStats"]["byPath"][number]; +type SectionRow = RouterOutputs["stats"]["getStats"]["bySection"][number]; + +const pathColumn = createDataTableColumnHelper(); +const sectionColumn = createDataTableColumnHelper(); + export default function StatsPage() { const { data: session, isPending } = useSession(); const { resolvedTheme } = useTheme(); @@ -53,6 +64,77 @@ export default function StatsPage() { const { data: siteStats, isLoading: siteStatsLoading } = api.stats.getSiteStats.useQuery(undefined, { enabled: !!canView }); + const pathColumns = useMemo[]>( + () => + pathColumn.columns([ + pathColumn.accessor((row) => row.path || "/", { + id: "path", + header: "Pfad", + meta: { + alwaysVisible: true, + cellClassName: "font-mono whitespace-nowrap", + }, + cell: ({ row }) => ( + { + if (!e.ctrlKey && !e.metaKey) { + e.preventDefault(); + } + }} + title="Strg+Klick (bzw. Cmd+Klick) zum Öffnen der Seite" + className="hover:text-primary dark:hover:text-primary inline-flex items-center gap-1.5 hover:underline" + > + {row.original.path || "/"} + + + ), + }), + pathColumn.accessor((row) => row.count, { + id: "count", + header: "Aufrufe", + meta: { + align: "right", + filterVariant: "number", + cellClassName: "tabular-nums whitespace-nowrap", + }, + cell: ({ row }) => ( + + ), + }), + ]), + [stats?.pathVisitorDetails], + ); + + const sectionColumns = useMemo[]>( + () => + sectionColumn.columns([ + sectionColumn.accessor((row) => row.section ?? "", { + id: "section", + header: "Bereich", + meta: { alwaysVisible: true, filterVariant: "set" }, + }), + sectionColumn.accessor((row) => row.count, { + id: "count", + header: "Aufrufe", + meta: { + align: "right", + filterVariant: "number", + cellClassName: "tabular-nums whitespace-nowrap", + }, + cell: ({ getValue }) => getValue().toLocaleString("de-DE"), + }), + ]), + [], + ); + useEffect(() => { if (!isPending && !session && !hasRedirected.current) { hasRedirected.current = true; @@ -282,56 +364,15 @@ export default function StatsPage() {
-
- - - - - - - - - {(showAllPaths - ? stats.byPath - : stats.byPath.slice(0, 5) - ).map((row) => ( - - - - - ))} - -
- Pfad - - Aufrufe -
- { - if (!e.ctrlKey && !e.metaKey) { - e.preventDefault(); - } - }} - title="Strg+Klick (bzw. Cmd+Klick) zum Öffnen der Seite" - className="hover:text-primary dark:hover:text-primary inline-flex items-center gap-1.5 hover:underline" - > - {row.path || "/"} - - - - -
-
+ row.path} + searchable={false} + paginated={false} + hideFooter + className="[&_table]:min-w-full" + /> {stats.byPath.length > 5 && (
-
- - - - - - - - - {stats.bySection.map((row) => ( - - - - - ))} - -
- Bereich - - Aufrufe -
- {row.section} - - {row.count.toLocaleString("de-DE")} -
-
+ row.section ?? ""} + searchable={false} + paginated={false} + hideFooter + className="[&_table]:min-w-full" + />
)} diff --git a/src/app/dashboard/team/page.tsx b/src/app/dashboard/team/page.tsx index 412e9877..806e9ae3 100644 --- a/src/app/dashboard/team/page.tsx +++ b/src/app/dashboard/team/page.tsx @@ -1,16 +1,21 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useSession } from "@/lib/auth"; import { useToast } from "@/app/_components/ui/toast"; import { useRouter } from "next/navigation"; import { useEffect, useRef } from "react"; -import { api } from "@/trpc/react"; +import { api, type RouterOutputs } from "@/trpc/react"; import { usePermissions } from "@/lib/use-permissions"; import { PERMISSIONS } from "@/lib/permissions"; import Link from "next/link"; import Image from "next/image"; import { DashboardPage } from "@/app/_components/dashboard"; +import { + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; import { ChevronDownIcon, ChevronUpIcon, @@ -26,6 +31,10 @@ const CONTACT_TYPE_LABELS: Record = { INTERNET_TEAM: "Internet-Team", }; +type TeamMember = RouterOutputs["organization"]["getTeam"][number]; + +const column = createDataTableColumnHelper(); + export default function DashboardTeamPage() { const router = useRouter(); const { data: session, isPending } = useSession(); @@ -121,6 +130,179 @@ export default function DashboardTeamPage() { } }; + const columns = useMemo[]>( + () => + column.columns([ + // Die gespeicherte Reihenfolge als eigene Spalte: nur so bleiben die + // Hoch/Runter-Pfeile nachvollziehbar, wenn nach etwas anderem sortiert + // wird — sie verschieben immer die gespeicherte Position, nie die Sicht. + column.accessor((member) => (teamMembers?.indexOf(member) ?? 0) + 1, { + id: "position", + header: "#", + enableColumnFilter: false, + meta: { align: "right", label: "Reihenfolge" }, + }), + column.accessor((member) => member.person.name || "Unbekannt", { + id: "member", + header: "Mitglied", + meta: { alwaysVisible: true }, + cell: ({ row }) => { + const member = row.original; + const displayName = member.person.name || "Unbekannt"; + const imageUrl = member.person.image?.url; + return ( +
+ {imageUrl ? ( +
+ {displayName} +
+ ) : ( +
+ + {displayName.charAt(0).toUpperCase()} + +
+ )} +
+ + {displayName} + + {!member.userId && ( + + ohne Konto + + )} +

+ {member.person.email || "-"} +

+
+
+ ); + }, + }), + column.accessor((member) => member.role ?? "", { + id: "role", + header: "Rolle", + cell: ({ getValue }) => getValue() || "-", + }), + column.accessor( + (member) => + member.contactType + ? (CONTACT_TYPE_LABELS[member.contactType] ?? member.contactType) + : "", + { + id: "contactType", + header: "Bereich", + meta: { filterVariant: "set" }, + cell: ({ getValue }) => + getValue() ? ( + + {getValue()} + + ) : ( + + - + + ), + }, + ), + column.accessor( + (member) => (member.responsibilities ?? []).join(" · "), + { + id: "responsibilities", + header: "Aufgaben", + cell: ({ row }) => { + const items = row.original.responsibilities ?? []; + if (items.length === 0) { + return ( + + - + + ); + } + return ( +
    + {items.slice(0, 2).map((entry, i) => ( +
  • + • {entry} +
  • + ))} + {items.length > 2 && ( +
  • + + {items.length - 2} weitere +
  • + )} +
+ ); + }, + }, + ), + column.display({ + id: "actions", + header: "Aktionen", + meta: { align: "right", label: "Aktionen" }, + cell: ({ row }) => { + const index = teamMembers?.indexOf(row.original) ?? -1; + return ( +
+ + + + + + +
+ ); + }, + }), + ]), + // eslint-disable-next-line react-hooks/exhaustive-deps + [teamMembers, deletingId, isReordering], + ); + if (isPending || profileLoading || membersLoading) { return (
@@ -151,188 +333,32 @@ export default function DashboardTeamPage() { } > - {/* Members List */} - {!teamMembers || teamMembers.length === 0 ? ( -
-
- -
-

- Keine Teammitglieder -

-

- Es wurden noch keine Teammitglieder angelegt. -

- - Erstes Mitglied anlegen - -
- ) : ( -
-
- - - - - - - - - - - - {teamMembers.map((member, index) => { - const displayName = member.person.name || "Unbekannt"; - const displayEmail = member.person.email || "-"; - const imageUrl = member.person.image?.url; - - return ( - - - - - - - - ); - })} - -
- Mitglied - - Rolle - - Bereich - - Aufgaben - - Aktionen -
-
- {imageUrl ? ( -
- {displayName} -
- ) : ( -
- - {displayName.charAt(0).toUpperCase()} - -
- )} -
- - {displayName} - - {!member.userId && ( - - ohne Konto - - )} -

- {displayEmail} -

-
-
-
- - {member.role || "-"} - - - {member.contactType ? ( - - {CONTACT_TYPE_LABELS[member.contactType] || - member.contactType} - - ) : ( - - - - - )} - -
- {member.responsibilities && - member.responsibilities.length > 0 ? ( -
    - {member.responsibilities - .slice(0, 2) - .map((r, i) => ( -
  • - • {r} -
  • - ))} - {member.responsibilities.length > 2 && ( -
  • - + {member.responsibilities.length - 2} weitere -
  • - )} -
- ) : ( - - - - - )} -
-
-
- - - - - - -
-
-
-
- )} + member.id} + isLoading={membersLoading} + rowNoun={["Teammitglied", "Teammitglieder"]} + searchPlaceholder="Name, E-Mail oder Rolle suchen…" + initialSorting={[{ id: "position", desc: false }]} + emptyState={ + <> + +

+ Keine Teammitglieder +

+

+ Es wurden noch keine Teammitglieder angelegt. +

+ + Erstes Mitglied anlegen + + + } + /> ); } diff --git a/src/app/dashboard/vorstand/page.tsx b/src/app/dashboard/vorstand/page.tsx index ab44c73d..882408cb 100644 --- a/src/app/dashboard/vorstand/page.tsx +++ b/src/app/dashboard/vorstand/page.tsx @@ -1,16 +1,21 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useSession } from "@/lib/auth"; import { useToast } from "@/app/_components/ui/toast"; import { useRouter } from "next/navigation"; import { useEffect, useRef } from "react"; -import { api } from "@/trpc/react"; +import { api, type RouterOutputs } from "@/trpc/react"; import { usePermissions } from "@/lib/use-permissions"; import { PERMISSIONS } from "@/lib/permissions"; import Link from "next/link"; import Image from "next/image"; import { DashboardPage } from "@/app/_components/dashboard"; +import { + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; import { ChevronDownIcon, ChevronUpIcon, @@ -21,6 +26,19 @@ import { TrashIcon } from "lucide-react"; import { UsersIcon } from "lucide-react"; import { computeReorderUpdates } from "@/lib/reorder"; +type VorstandMember = RouterOutputs["organization"]["getVorstand"][number]; + +const column = createDataTableColumnHelper(); + +/** Der verknüpfte Benutzername schlägt den frei eingetragenen. */ +function memberName(member: VorstandMember): string { + return member.user?.displayName ?? member.name ?? "Unbekannt"; +} + +function memberEmail(member: VorstandMember): string { + return member.user?.email ?? member.email ?? "-"; +} + export default function DashboardVorstandPage() { const router = useRouter(); const { data: session, isPending } = useSession(); @@ -116,6 +134,165 @@ export default function DashboardVorstandPage() { } }; + const columns = useMemo[]>( + () => + column.columns([ + // Die gespeicherte Reihenfolge als eigene Spalte: nur so bleiben die + // Hoch/Runter-Pfeile nachvollziehbar, wenn nach etwas anderem sortiert + // wird — sie verschieben immer die gespeicherte Position, nie die Sicht. + column.accessor((member) => member.sortOrder, { + id: "sortOrder", + header: "#", + enableColumnFilter: false, + meta: { align: "right", label: "Reihenfolge" }, + }), + column.accessor(memberName, { + id: "member", + header: "Mitglied", + meta: { alwaysVisible: true }, + cell: ({ row }) => { + const member = row.original; + const displayName = memberName(member); + const imageUrl = + member.image?.url ?? member.user?.profileImage?.url; + return ( +
+ {imageUrl ? ( +
+ {displayName} +
+ ) : ( +
+ + {displayName.charAt(0).toUpperCase()} + +
+ )} + + {displayName} + +
+ ); + }, + }), + column.accessor((member) => member.position, { + id: "position", + header: "Position", + meta: { filterVariant: "set" }, + cell: ({ row }) => ( + + {row.original.position} + + ), + }), + column.accessor(memberEmail, { + id: "contact", + header: "Kontakt", + cell: ({ row }) => ( + <> +

+ {memberEmail(row.original)} +

+ {row.original.phone && ( +

+ {row.original.phone} +

+ )} + + ), + }), + column.accessor( + (member) => (member.userId ? "Verknüpft" : "Nicht verknüpft"), + { + id: "linked", + header: "Verknüpft", + meta: { filterVariant: "set" }, + cell: ({ row }) => + row.original.userId ? ( + + Benutzer verknüpft + + ) : ( + + Nicht verknüpft + + ), + }, + ), + column.display({ + id: "actions", + header: "Aktionen", + meta: { align: "right", label: "Aktionen" }, + cell: ({ row }) => { + const index = vorstandMembers?.indexOf(row.original) ?? -1; + return ( +
+ + + + + + +
+ ); + }, + }), + ]), + // eslint-disable-next-line react-hooks/exhaustive-deps + [vorstandMembers, deletingId, isReordering], + ); + if (isPending || profileLoading || membersLoading) { return (
@@ -146,179 +323,32 @@ export default function DashboardVorstandPage() { } > - {/* Members List */} - {!vorstandMembers || vorstandMembers.length === 0 ? ( -
-
- -
-

- Keine Vorstandsmitglieder -

-

- Es wurden noch keine Vorstandsmitglieder angelegt. -

- - Erstes Mitglied anlegen - -
- ) : ( -
-
- - - - - - - - - - - - {vorstandMembers.map((member, index) => { - const displayName = - member.user?.displayName || member.name || "Unbekannt"; - const displayEmail = - member.user?.email || member.email || "-"; - const imageUrl = - member.image?.url || member.user?.profileImage?.url; - - return ( - - - - - - - - ); - })} - -
- Mitglied - - Position - - Kontakt - - Verknüpft - - Aktionen -
-
- {imageUrl ? ( -
- {displayName} -
- ) : ( -
- - {displayName.charAt(0).toUpperCase()} - -
- )} -
- - {displayName} - -

- Reihenfolge: {member.sortOrder} -

-
-
-
- - {member.position} - - -

- {displayEmail} -

- {member.phone && ( -

- {member.phone} -

- )} -
- {member.userId ? ( - - Benutzer verknüpft - - ) : ( - - Nicht verknüpft - - )} - -
- - - - - - -
-
-
-
- )} + member.id} + isLoading={membersLoading} + rowNoun={["Vorstandsmitglied", "Vorstandsmitglieder"]} + searchPlaceholder="Name, Position oder Kontakt suchen…" + initialSorting={[{ id: "position", desc: false }]} + emptyState={ + <> + +

+ Keine Vorstandsmitglieder +

+

+ Es wurden noch keine Vorstandsmitglieder angelegt. +

+ + Erstes Mitglied anlegen + + + } + /> ); } From 54bbeb477a1653b89c455795d55fab95006302ec Mon Sep 17 00:00:00 2001 From: Lars Lehmann Date: Thu, 10 Sep 2026 15:17:18 +0200 Subject: [PATCH 4/9] feat(invoices): vom Rechnungsarchiv in die Kursrechnungen springen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Archiv listet Rechnungen vieler Kurse nebeneinander, bot aber keinen Weg in die Rechnungsliste des jeweiligen Kurses. Der Kurstitel war bewusst kein Link, weil diese Seite nur Kursverwaltungen offensteht, das Archiv aber jeder Person mit `invoices.view`. `manageableCourseIds` löst die Abrechnungsberechtigung jetzt für eine ganze Kursliste auf einmal auf, statt für jede Zeile einzeln. Damit kann das Archiv genau die Titel verlinken, hinter denen auch wirklich eine zugängliche Seite liegt; die übrigen bleiben Text. Ist genau ein Kurs gefiltert, führt zusätzlich ein Knopf im Seitenkopf dorthin. Die Filterleiste des Archivs wandert in die Spaltenköpfe: Status, Kurs und Jahr sind Set-Filter, die Suche und die Sortierung laufen weiter über die Abfrage. Die Rechnungsliste eines Kurses wird von einer Aufzählung zur Tabelle und zeigt die Teilnehmer:innen mit an. --- .../dashboard/courses/[id]/invoices/page.tsx | 203 +++++-- src/app/dashboard/invoices/page.tsx | 531 +++++++++--------- src/server/api/helpers/invoice-access.ts | 44 ++ src/server/api/routers/invoices.ts | 137 ++++- 4 files changed, 574 insertions(+), 341 deletions(-) diff --git a/src/app/dashboard/courses/[id]/invoices/page.tsx b/src/app/dashboard/courses/[id]/invoices/page.tsx index 322dc3b8..3f7e0c2b 100644 --- a/src/app/dashboard/courses/[id]/invoices/page.tsx +++ b/src/app/dashboard/courses/[id]/invoices/page.tsx @@ -4,8 +4,13 @@ import { useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { useSession } from "@/lib/auth"; -import { api } from "@/trpc/react"; +import { api, type RouterOutputs } from "@/trpc/react"; import DashboardPage from "@/app/_components/dashboard/dashboard-page"; +import { + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; import { InvoicePaymentBadge } from "@/app/_components/dashboard/invoice-payment-badge"; import { InvoiceStatusBadge, @@ -61,6 +66,22 @@ function siblingDiscountAmount(lineItems: unknown): number { return -discountTotal; } +type CourseInvoice = RouterOutputs["invoices"]["listForCourse"][number]; + +const invoiceColumn = createDataTableColumnHelper(); + +/** Firma und Person der Rechnungsanschrift als eine Zeile. */ +function invoiceRecipient(invoice: CourseInvoice): string { + return ( + [ + invoice.recipientCompany, + `${invoice.recipientFirstName ?? ""} ${invoice.recipientLastName ?? ""}`.trim(), + ] + .filter(Boolean) + .join(" · ") || "—" + ); +} + type SignatureMode = "none" | "upload" | "draw"; /** Mirrors the signature cap in the publish input on the server. */ @@ -203,6 +224,110 @@ export default function CourseInvoicesPage() { onError: (error) => toast.error(error.message), }); + const invoiceColumns = useMemo[]>( + () => + invoiceColumn.columns([ + invoiceColumn.accessor( + (invoice) => invoice.invoiceNumber ?? "Entwurf", + { + id: "invoiceNumber", + header: "Nummer", + meta: { alwaysVisible: true, cellClassName: "whitespace-nowrap" }, + cell: ({ row }) => ( + <> + + {row.original.invoiceNumber ?? "Entwurf"} + + {row.original.replaces?.invoiceNumber && ( + + ersetzt {row.original.replaces.invoiceNumber} + + )} + {row.original.replacedBy?.invoiceNumber && ( + + ersetzt durch {row.original.replacedBy.invoiceNumber} + + )} + + ), + }, + ), + invoiceColumn.accessor(invoiceRecipient, { + id: "recipient", + header: "Empfänger", + cell: ({ row }) => ( + <> + {invoiceRecipient(row.original)} + {row.original.recipientEmail && ( + + {row.original.recipientEmail} + + )} + + ), + }), + invoiceColumn.accessor( + (invoice) => + (invoice.registration?.participants ?? []) + .map((p) => `${p.firstName} ${p.lastName}`.trim()) + .join(", "), + { + id: "participants", + header: "Teilnehmer:innen", + cell: ({ getValue }) => ( + + {getValue() || "—"} + + ), + }, + ), + invoiceColumn.accessor((invoice) => invoice.invoiceDate, { + id: "invoiceDate", + header: "Datum", + sortFn: "datetime", + sortUndefined: "last", + meta: { filterVariant: "date", cellClassName: "whitespace-nowrap" }, + cell: ({ row }) => + row.original.invoiceDate + ? formatDate(row.original.invoiceDate) + : "—", + }), + invoiceColumn.accessor((invoice) => invoice.totalAmount, { + id: "totalAmount", + header: "Betrag", + meta: { + align: "right", + filterVariant: "number", + cellClassName: "font-semibold whitespace-nowrap", + }, + cell: ({ getValue }) => formatEuro(getValue()), + }), + invoiceColumn.accessor( + (invoice) => INVOICE_STATUS_LABELS[invoice.status], + { + id: "status", + header: "Status", + meta: { + filterVariant: "set", + filterOptions: Object.values(INVOICE_STATUS_LABELS).map( + (label) => ({ value: label, label }), + ), + }, + cell: ({ row }) => ( +
+ + +
+ ), + }, + ), + ]), + [courseId], + ); + const summary = useMemo(() => { const list = invoices ?? []; return { @@ -539,13 +664,19 @@ export default function CourseInvoicesPage() { )} {/* Invoice list */} -
- {invoicesLoading ? ( -

- Lade… -

- ) : (invoices?.length ?? 0) === 0 ? ( -
+ invoice.id} + isLoading={invoicesLoading} + rowNoun={["Rechnung", "Rechnungen"]} + searchPlaceholder="Nummer, Empfänger oder Anmeldung" + initialSorting={[{ id: "invoiceDate", desc: true }]} + onRowClick={(invoice) => + router.push(`/dashboard/courses/${courseId}/invoices/${invoice.id}`) + } + emptyState={ + <>

Noch keine Rechnungen @@ -553,59 +684,9 @@ export default function CourseInvoicesPage() {

Erstelle den ersten Entwurf aus einer Anmeldung.

-
- ) : ( -
    - {invoices?.map((invoice) => ( -
  • - -
    -
    - - {invoice.invoiceNumber ?? "Entwurf"} - - - - {invoice.replaces?.invoiceNumber && ( - - ersetzt {invoice.replaces.invoiceNumber} - - )} - {invoice.replacedBy?.invoiceNumber && ( - - ersetzt durch {invoice.replacedBy.invoiceNumber} - - )} -
    -

    - {[ - invoice.recipientCompany, - `${invoice.recipientFirstName ?? ""} ${invoice.recipientLastName ?? ""}`.trim(), - ] - .filter(Boolean) - .join(" · ")} - {invoice.invoiceDate - ? ` · ${formatDate(invoice.invoiceDate)}` - : ""} -

    -
    -
    -

    - {formatEuro(invoice.totalAmount)} -

    -

    - {INVOICE_STATUS_LABELS[invoice.status]} -

    -
    - -
  • - ))} -
- )} -
+ + } + /> {summary.published.length > 0 && (
diff --git a/src/app/dashboard/invoices/page.tsx b/src/app/dashboard/invoices/page.tsx index 971dcce8..3a5a380e 100644 --- a/src/app/dashboard/invoices/page.tsx +++ b/src/app/dashboard/invoices/page.tsx @@ -1,9 +1,9 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import Link from "next/link"; import { useSession } from "@/lib/auth"; -import { api } from "@/trpc/react"; +import { api, type RouterOutputs } from "@/trpc/react"; import DashboardPage from "@/app/_components/dashboard/dashboard-page"; import { InvoiceStatusBadge } from "@/app/_components/dashboard/invoice-status-badge"; import { usePermissions } from "@/lib/use-permissions"; @@ -12,43 +12,98 @@ import { formatDate, formatEuro } from "@/lib/invoice-document"; import { InvoiceStatus } from "~/generated/prisma/enums"; import { InvoicePaymentBadge } from "@/app/_components/dashboard/invoice-payment-badge"; import { invoiceOpenAmount } from "@/lib/invoice-payment"; -import { DownloadIcon, ReceiptTextIcon, SearchIcon } from "lucide-react"; +import { + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; +import type { + ColumnFiltersState, + PaginationState, + SortingState, +} from "@tanstack/react-table"; +import { DownloadIcon, ReceiptTextIcon } from "lucide-react"; -const PAGE_SIZE = 25; +type ArchiveInvoice = RouterOutputs["invoices"]["list"]["invoices"][number]; -const statusOptions = [ - { value: "", label: "Alle Status" }, +/** The column ids the server can sort by, keyed by table column id. */ +const SORTABLE_COLUMNS = { + invoiceNumber: "invoiceNumber", + recipient: "recipient", + course: "course", + invoiceDate: "invoiceDate", + totalAmount: "totalAmount", + status: "status", +} as const; + +type SortableColumn = keyof typeof SORTABLE_COLUMNS; + +const STATUS_OPTIONS = [ { value: InvoiceStatus.DRAFT, label: "Entwurf" }, { value: InvoiceStatus.PUBLISHED, label: "Ausgestellt" }, { value: InvoiceStatus.CANCELLED, label: "Storniert" }, ]; -/** Invoice years to offer in the filter: the current one and the four before. */ +/** Invoice years to offer in the date filter: the current one and the four before. */ function recentYears(): number[] { const current = new Date().getFullYear(); return [0, 1, 2, 3, 4].map((offset) => current - offset); } +const column = createDataTableColumnHelper(); + +function recipientName(invoice: ArchiveInvoice): string { + return ( + [ + invoice.recipientCompany, + `${invoice.recipientFirstName ?? ""} ${invoice.recipientLastName ?? ""}`.trim(), + ] + .filter(Boolean) + .join(" · ") || "—" + ); +} + +/** Reads one set filter out of the table's filter state. */ +function setFilter(filters: ColumnFiltersState, id: string): string[] { + const value = filters.find((filter) => filter.id === id)?.value; + return Array.isArray(value) ? (value as string[]) : []; +} + export default function InvoiceArchivePage() { const { data: session, isPending: sessionLoading } = useSession(); const { hasPermission, isLoading: permissionsLoading } = usePermissions(); const canView = hasPermission("invoices.view" as PermissionKey); - const [page, setPage] = useState(1); - const [status, setStatus] = useState(""); - const [courseId, setCourseId] = useState(""); - const [year, setYear] = useState(""); - const [searchInput, setSearchInput] = useState(""); + // The archive is paged on the server, so sorting, the set filters and the + // search term are query input rather than something the table does locally — + // otherwise each of them would only ever see the 25 rows already fetched. + const [sorting, setSorting] = useState([ + { id: "invoiceDate", desc: true }, + ]); + const [columnFilters, setColumnFilters] = useState([]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 25, + }); const [search, setSearch] = useState(""); + const sortBy = (sorting[0]?.id ?? "invoiceDate") as SortableColumn; + const courseFilter = setFilter(columnFilters, "course"); + const statusFilter = setFilter(columnFilters, "status"); + const yearFilter = setFilter(columnFilters, "invoiceDate"); + const { data, isLoading } = api.invoices.list.useQuery( { - page, - limit: PAGE_SIZE, - status: status ? (status as InvoiceStatus) : undefined, - courseId: courseId || undefined, - year: year ? Number(year) : undefined, + page: pagination.pageIndex + 1, + limit: pagination.pageSize, + status: statusFilter.length + ? (statusFilter as InvoiceStatus[]) + : undefined, + courseId: courseFilter.length ? courseFilter : undefined, + year: yearFilter.length ? yearFilter.map(Number) : undefined, search: search || undefined, + sortBy: SORTABLE_COLUMNS[sortBy] ?? "invoiceDate", + sortOrder: sorting[0]?.desc === false ? "asc" : "desc", }, { enabled: !!session?.user && canView }, ); @@ -57,7 +112,168 @@ export default function InvoiceArchivePage() { enabled: !!session?.user && canView, }); - const resetToFirstPage = () => setPage(1); + const manageableCourses = useMemo( + () => new Set(data?.manageableCourseIds ?? []), + [data?.manageableCourseIds], + ); + + const columns = useMemo[]>( + () => + column.columns([ + column.accessor((invoice) => invoice.invoiceNumber ?? "Entwurf", { + id: "invoiceNumber", + header: "Nummer", + enableColumnFilter: false, + meta: { alwaysVisible: true }, + cell: ({ row }) => ( + <> + + {row.original.invoiceNumber ?? "Entwurf"} + + {row.original.replaces?.invoiceNumber && ( + + ersetzt {row.original.replaces.invoiceNumber} + + )} + {row.original.replacedBy?.invoiceNumber && ( + + ersetzt durch {row.original.replacedBy.invoiceNumber} + + )} + + ), + }), + column.accessor(recipientName, { + id: "recipient", + header: "Empfänger", + enableColumnFilter: false, + cell: ({ row }) => ( + <> + {recipientName(row.original)} + {row.original.recipientEmail && ( + + {row.original.recipientEmail} + + )} + + ), + }), + column.accessor((invoice) => invoice.course.id, { + id: "course", + header: "Kurs", + meta: { + filterVariant: "set", + label: "Kurs", + filterOptions: (courses ?? []).map((course) => ({ + value: course.id, + label: `${course.title} (${course.invoiceCount})`, + })), + }, + cell: ({ row }) => { + const course = row.original.course; + // Kurs-Organisator:innen springen von hier direkt in die + // Rechnungsliste des Kurses; wer den Kurs nicht abrechnen darf, + // bekommt weiterhin nur den Titel — die Seite wäre für sie gesperrt. + return manageableCourses.has(course.id) ? ( + + + {course.title} + + ) : ( + + {course.title} + + ); + }, + }), + column.accessor((invoice) => invoice.invoiceDate, { + id: "invoiceDate", + header: "Datum", + meta: { + filterVariant: "set", + label: "Jahr", + cellClassName: "whitespace-nowrap", + filterOptions: recentYears().map((year) => ({ + value: String(year), + label: String(year), + })), + }, + cell: ({ row }) => + row.original.invoiceDate + ? formatDate(row.original.invoiceDate) + : "—", + }), + column.accessor((invoice) => invoice.totalAmount, { + id: "totalAmount", + header: "Betrag", + enableColumnFilter: false, + meta: { + align: "right", + cellClassName: "whitespace-nowrap font-medium", + }, + cell: ({ row }) => { + const invoice = row.original; + return ( + <> + {formatEuro(invoice.totalAmount)} + {invoice.status === InvoiceStatus.PUBLISHED && + (invoice.paidAt ? ( + + {invoiceOpenAmount(invoice) > 0 + ? `${formatEuro(invoiceOpenAmount(invoice))} offen` + : "bezahlt"} + + ) : ( + + offen + + ))} + + ); + }, + }), + column.accessor((invoice) => invoice.status, { + id: "status", + header: "Status", + meta: { filterVariant: "set", filterOptions: STATUS_OPTIONS }, + cell: ({ row }) => ( +
+ + +
+ ), + }), + column.display({ + id: "pdf", + header: "PDF", + meta: { align: "right", label: "PDF" }, + cell: ({ row }) => + row.original.pdfPath ? ( + + + + ) : ( + + — + + ), + }), + ]), + [courses, manageableCourses], + ); if (sessionLoading || permissionsLoading) { return ( @@ -85,96 +301,29 @@ export default function InvoiceArchivePage() { ); } - const selectClass = - "dark:border-dark-border dark:bg-dark-background dark:text-dark-text rounded-md border border-gray-300 px-3 py-2 text-sm"; + // Genau ein Kurs ausgewählt und abrechenbar: dann ist der Sprung in dessen + // Rechnungsliste die wahrscheinlichste nächste Handlung. + const selectedCourse = + courseFilter.length === 1 + ? courses?.find((course) => course.id === courseFilter[0]) + : undefined; return ( - {/* Filters */} -
-
-
{ - e.preventDefault(); - setSearch(searchInput.trim()); - resetToFirstPage(); - }} - > -
- - setSearchInput(e.target.value)} - placeholder="Nummer, Empfänger oder Kurs" - aria-label="Rechnungen durchsuchen" - className={`${selectClass} w-full pl-9`} - /> -
- -
- - - - - - -
-
- + + Rechnungen verwalten + + ) : undefined + } + > {/* Summary */}
@@ -209,14 +358,15 @@ export default function InvoiceArchivePage() {
- {/* Table */} -
- {isLoading ? ( -

- Lade… -

- ) : (data?.invoices.length ?? 0) === 0 ? ( -
+ invoice.id} + isLoading={isLoading} + rowNoun={["Rechnung", "Rechnungen"]} + searchPlaceholder="Nummer, Empfänger oder Kurs" + emptyState={ + <>

Keine Rechnungen gefunden @@ -224,146 +374,27 @@ export default function InvoiceArchivePage() {

Passe die Filter an oder erstelle Rechnungen im jeweiligen Kurs.

-
- ) : ( -
- - - - - - - - - - - - - - {data?.invoices.map((invoice) => ( - - - - {/* Not a link to the course's invoice list: that page is - organizer-only, and this archive is open to anyone with - invoices.view. The number beside it opens the read-only - detail view instead. */} - - - - - - - ))} - -
NummerEmpfängerKursDatumBetragStatusPDF
- - {invoice.invoiceNumber ?? "Entwurf"} - - {invoice.replaces?.invoiceNumber && ( - - ersetzt {invoice.replaces.invoiceNumber} - - )} - {invoice.replacedBy?.invoiceNumber && ( - - ersetzt durch {invoice.replacedBy.invoiceNumber} - - )} - - - {[ - invoice.recipientCompany, - `${invoice.recipientFirstName ?? ""} ${invoice.recipientLastName ?? ""}`.trim(), - ] - .filter(Boolean) - .join(" · ") || "—"} - - {invoice.recipientEmail && ( - - {invoice.recipientEmail} - - )} - - {invoice.course.title} - - {invoice.invoiceDate - ? formatDate(invoice.invoiceDate) - : "—"} - - {formatEuro(invoice.totalAmount)} - {invoice.status === InvoiceStatus.PUBLISHED && - (invoice.paidAt ? ( - - {invoiceOpenAmount(invoice) > 0 - ? `${formatEuro(invoiceOpenAmount(invoice))} offen` - : "bezahlt"} - - ) : ( - - offen - - ))} - -
- - -
-
- {invoice.pdfPath ? ( - - - - ) : ( - - — - - )} -
-
- )} -
- - {/* Pagination */} - {(data?.pages ?? 0) > 1 && ( -
- - - Seite {page} von {data?.pages} - - -
- )} + + } + sorting={sorting} + onSortingChange={setSorting} + manualSorting + columnFilters={columnFilters} + onColumnFiltersChange={(updater) => { + setColumnFilters(updater); + setPagination((current) => ({ ...current, pageIndex: 0 })); + }} + manualFiltering + search={search} + onSearchChange={(value) => { + setSearch(value); + setPagination((current) => ({ ...current, pageIndex: 0 })); + }} + pagination={pagination} + onPaginationChange={setPagination} + manualPagination + rowCount={data?.total ?? 0} + />
); } diff --git a/src/server/api/helpers/invoice-access.ts b/src/server/api/helpers/invoice-access.ts index 49422f9d..fc830676 100644 --- a/src/server/api/helpers/invoice-access.ts +++ b/src/server/api/helpers/invoice-access.ts @@ -87,3 +87,47 @@ export async function userCanBookInvoicePayments( ); return canBookInvoicePayments(access, userId, permissionCache); } + +/** + * Welche der übergebenen Kurse die Person abrechnen darf — dieselbe Regel wie + * {@link resolveInvoiceAccess}, nur für eine ganze Liste auf einmal. + * + * Das Rechnungsarchiv zeigt Rechnungen aus vielen Kursen nebeneinander und muss + * pro Zeile wissen, ob der Sprung in die Kursrechnungen offensteht. Einzeln + * aufgelöst wäre das eine Collaborator-Abfrage pro Zeile. + */ +export async function manageableCourseIds( + db: PrismaClient, + userId: string, + courseIds: string[], + permissionCache?: PermissionCache, +): Promise> { + if (courseIds.length === 0) return new Set(); + + const hasGlobalGrant = await userHasPermission( + userId, + PERMISSIONS.INVOICES_GENERATE, + permissionCache, + ); + if (hasGlobalGrant) return new Set(courseIds); + + const [own, collaborations] = await Promise.all([ + db.course.findMany({ + where: { id: { in: courseIds }, createdById: userId }, + select: { id: true }, + }), + db.courseCollaborator.findMany({ + where: { + courseId: { in: courseIds }, + userId, + role: CourseCollaboratorRole.ORGANIZER, + }, + select: { courseId: true }, + }), + ]); + + return new Set([ + ...own.map((course) => course.id), + ...collaborations.map((entry) => entry.courseId), + ]); +} diff --git a/src/server/api/routers/invoices.ts b/src/server/api/routers/invoices.ts index e511f637..0e447a8d 100644 --- a/src/server/api/routers/invoices.ts +++ b/src/server/api/routers/invoices.ts @@ -13,6 +13,7 @@ import { } from "../helpers/permissions"; import { canBookInvoicePayments, + manageableCourseIds, resolveInvoiceAccess, } from "../helpers/invoice-access"; import { permissionProcedure } from "../middleware/permissions"; @@ -1190,51 +1191,108 @@ export const invoicesRouter = createTRPCRouter({ .input( z.object({ page: z.number().int().min(1).default(1), - limit: z.number().int().min(1).max(100).default(25), - courseId: z.string().optional(), - status: z.nativeEnum(InvoiceStatus).optional(), - /** Calendar year of the invoice date. */ - year: z.number().int().min(2000).max(2200).optional(), + limit: z.number().int().min(1).max(250).default(25), + /* Set filters: an empty array means "no restriction", like omitting it. */ + courseId: z.array(z.string()).optional(), + status: z.array(z.nativeEnum(InvoiceStatus)).optional(), + /** Calendar years of the invoice date. */ + year: z.array(z.number().int().min(2000).max(2200)).optional(), search: z.string().trim().max(200).optional(), + sortBy: z + .enum([ + "invoiceNumber", + "recipient", + "course", + "invoiceDate", + "totalAmount", + "status", + "createdAt", + ]) + .default("createdAt"), + sortOrder: z.enum(["asc", "desc"]).default("desc"), }), ) .query(async ({ ctx, input }) => { const search = input.search; + const courseIds = input.courseId?.length ? input.courseId : undefined; + const statuses = input.status?.length ? input.status : undefined; + const years = input.year?.length ? input.year : undefined; + + // Year set and free-text search are both `OR` groups; they have to sit in + // separate `AND` members or the object spread would drop one of them. const where: Prisma.InvoiceWhereInput = { - ...(input.courseId ? { courseId: input.courseId } : {}), - ...(input.status ? { status: input.status } : {}), - ...(input.year - ? { - invoiceDate: { - gte: new Date(Date.UTC(input.year, 0, 1)), - lt: new Date(Date.UTC(input.year + 1, 0, 1)), - }, - } - : {}), - ...(search - ? { - OR: [ - { invoiceNumber: { contains: search, mode: "insensitive" } }, + ...(courseIds ? { courseId: { in: courseIds } } : {}), + ...(statuses ? { status: { in: statuses } } : {}), + AND: [ + ...(years + ? [ { - recipientLastName: { contains: search, mode: "insensitive" }, + OR: years.map((year) => ({ + invoiceDate: { + gte: new Date(Date.UTC(year, 0, 1)), + lt: new Date(Date.UTC(year + 1, 0, 1)), + }, + })), }, + ] + : []), + ...(search + ? [ { - recipientFirstName: { contains: search, mode: "insensitive" }, - }, - { recipientCompany: { contains: search, mode: "insensitive" } }, - { recipientEmail: { contains: search, mode: "insensitive" } }, - { - course: { title: { contains: search, mode: "insensitive" } }, + OR: [ + { + invoiceNumber: { contains: search, mode: "insensitive" }, + }, + { + recipientLastName: { + contains: search, + mode: "insensitive", + }, + }, + { + recipientFirstName: { + contains: search, + mode: "insensitive", + }, + }, + { + recipientCompany: { + contains: search, + mode: "insensitive", + }, + }, + { + recipientEmail: { contains: search, mode: "insensitive" }, + }, + { + course: { + title: { contains: search, mode: "insensitive" }, + }, + }, + ] satisfies Prisma.InvoiceWhereInput[], }, - ], - } - : {}), + ] + : []), + ], }; + const direction = input.sortOrder; + const orderBy: Prisma.InvoiceOrderByWithRelationInput[] = + input.sortBy === "recipient" + ? [ + { recipientLastName: direction }, + { recipientFirstName: direction }, + ] + : input.sortBy === "course" + ? [{ course: { title: direction } }] + : [{ [input.sortBy]: direction }]; + // Deterministic tiebreaker, so paging cannot show the same row twice. + if (input.sortBy !== "createdAt") orderBy.push({ createdAt: "desc" }); + const [invoices, total, totals, openRows] = await Promise.all([ ctx.db.invoice.findMany({ where, - orderBy: [{ createdAt: "desc" }], + orderBy, skip: (input.page - 1) * input.limit, take: input.limit, include: { @@ -1261,6 +1319,15 @@ export const invoicesRouter = createTRPCRouter({ }), ]); + // Which of the courses on this page the viewer may actually invoice — + // the archive links only those titles into the course's invoice list. + const manageable = await manageableCourseIds( + ctx.db, + ctx.session.user.id, + [...new Set(invoices.map((invoice) => invoice.courseId))], + ctx.permissionCache, + ); + return { invoices, total, @@ -1272,6 +1339,7 @@ export const invoicesRouter = createTRPCRouter({ (sum, row) => sum + invoiceOpenAmount(row), 0, ), + manageableCourseIds: [...manageable], }; }), @@ -1290,12 +1358,21 @@ export const invoicesRouter = createTRPCRouter({ orderBy: { startDate: "desc" }, }); + const manageable = await manageableCourseIds( + ctx.db, + ctx.session.user.id, + courses.map((course) => course.id), + ctx.permissionCache, + ); + const counts = new Map( grouped.map((row) => [row.courseId, row._count._all]), ); return courses.map((course) => ({ ...course, invoiceCount: counts.get(course.id) ?? 0, + /** Whether the viewer may open this course's invoice list. */ + canManage: manageable.has(course.id), })); }, ), From 83897c8de7cfa2d526cbdee8f9a92c287bed0ecb Mon Sep 17 00:00:00 2001 From: Lars Lehmann Date: Thu, 10 Sep 2026 15:17:42 +0200 Subject: [PATCH 5/9] feat(courses): Rechnungen-Knopf auf allen Kursseiten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Weg in die Rechnungen eines Kurses lag bisher an zwei Stellen und an beiden versteckt: einmal nur im Teilnehmer-Reiter der Kursübersicht, einmal unten im Export-Menü der Teilnehmerliste. Von der Mail-Seite, der Kursbearbeitung und einer einzelnen Anmeldung führte gar kein Weg dorthin. Ein gemeinsamer Knopf sitzt jetzt im Kopf jeder Kursseite. Ob er erscheint, entscheidet der Server über `canManageCourseInvoices` — nicht die einzelne Seite, sonst driften Knopf und Guard auseinander und es entsteht entweder ein Knopf, der 403 wirft, oder eine Berechtigung ohne Knopf. Die beiden bisherigen Einsprünge entfallen dafür. Er erscheint auch, wenn die Rechnungsstellung für den Kurs nicht freigeschaltet ist: die Rechnungsseite zeigt dann die bereits bestehenden Rechnungen samt Hinweisbanner, und genau dorthin will man in dem Fall. Das Formular für eine neue Anmeldung bleibt außen vor — ein Absprung mitten im Ausfüllen kostet die Eingaben. Die Teilnehmer-Ansicht wird dabei zur Tabelle: eine Zeile je Teilnehmer:in, mit Sortierung und Spaltenfiltern auch über die kursspezifischen Zusatzfelder. --- .../dashboard/course-invoices-button.tsx | 57 +++ src/app/_components/dashboard/index.ts | 1 + src/app/dashboard/courses/[id]/edit/page.tsx | 2 + src/app/dashboard/courses/[id]/mail/page.tsx | 22 +- src/app/dashboard/courses/[id]/page.tsx | 19 +- .../participants/[registrationId]/page.tsx | 6 +- .../courses/[id]/participants/page.tsx | 396 ++++++++++-------- 7 files changed, 307 insertions(+), 196 deletions(-) create mode 100644 src/app/_components/dashboard/course-invoices-button.tsx diff --git a/src/app/_components/dashboard/course-invoices-button.tsx b/src/app/_components/dashboard/course-invoices-button.tsx new file mode 100644 index 00000000..b91e6e76 --- /dev/null +++ b/src/app/_components/dashboard/course-invoices-button.tsx @@ -0,0 +1,57 @@ +"use client"; + +import Link from "next/link"; +import { ReceiptTextIcon } from "lucide-react"; +import { useSession } from "@/lib/auth"; +import { api } from "@/trpc/react"; + +interface CourseInvoicesButtonProps { + courseId: string; + /** `primary` for pages where invoicing is the point, `secondary` elsewhere. */ + variant?: "primary" | "secondary"; + /** Shortens the label to "Rechnungen" where header space is tight. */ + short?: boolean; + className?: string; +} + +/** + * Der Sprung in die Rechnungsliste eines Kurses — auf jeder Kursseite derselbe + * Knopf, damit man ihn nicht auf jeder Seite woanders suchen muss. + * + * Ob er erscheint, entscheidet der Server (`canManageCourseInvoices`), nicht die + * Seite: sonst driften Knopf und Guard auseinander und es entsteht entweder ein + * Knopf, der 403 wirft, oder eine Berechtigung ohne Knopf. + * + * Bewusst auch dann sichtbar, wenn für den Kurs `invoicingEnabled` aus ist: die + * Rechnungsseite zeigt in dem Fall die bereits bestehenden Rechnungen samt + * Hinweisbanner, und genau dorthin will man dann. + */ +export function CourseInvoicesButton({ + courseId, + variant = "secondary", + short = false, + className, +}: CourseInvoicesButtonProps) { + const { data: session } = useSession(); + const { data: access } = api.invoices.canManageCourseInvoices.useQuery( + { courseId }, + { enabled: !!courseId && !!session?.user }, + ); + + if (!access?.canManage) return null; + + const styles = + variant === "primary" + ? "bg-primary hover:bg-primary/90 text-white" + : "dark:border-dark-border dark:bg-dark-surface dark:text-dark-text border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700"; + + return ( + + + {short ? "Rechnungen" : "Rechnungen verwalten"} + + ); +} diff --git a/src/app/_components/dashboard/index.ts b/src/app/_components/dashboard/index.ts index bf1e5f07..541a026e 100644 --- a/src/app/_components/dashboard/index.ts +++ b/src/app/_components/dashboard/index.ts @@ -45,3 +45,4 @@ export { type CreatedLocation, } from "./new-location-form"; export { SocialLinksEditor, cleanSocialLinks } from "./social-links-editor"; +export { CourseInvoicesButton } from "./course-invoices-button"; diff --git a/src/app/dashboard/courses/[id]/edit/page.tsx b/src/app/dashboard/courses/[id]/edit/page.tsx index 46a225fc..3e97b85e 100644 --- a/src/app/dashboard/courses/[id]/edit/page.tsx +++ b/src/app/dashboard/courses/[id]/edit/page.tsx @@ -36,6 +36,7 @@ import { DashboardFormZoneHeader, DashboardFormBlock, CourseFormEditMetaBar, + CourseInvoicesButton, CourseCustomFieldsEditor, DraftRestorePrompt, SlugField, @@ -1089,6 +1090,7 @@ export default function EditCoursePage() { { label: course.title, href: `/dashboard/courses/${courseId}` }, { label: "Bearbeiten" }, ]} + actions={} maxWidth="7xl" >
- - - Zurück zu den Teilnehmern - +
+ + + + Zurück zu den Teilnehmern + +
{selectedRegistrationIds.length > 0 && ( diff --git a/src/app/dashboard/courses/[id]/page.tsx b/src/app/dashboard/courses/[id]/page.tsx index df928496..0abc321e 100644 --- a/src/app/dashboard/courses/[id]/page.tsx +++ b/src/app/dashboard/courses/[id]/page.tsx @@ -23,12 +23,12 @@ import { ExternalLink, MailIcon, PlusIcon, - ReceiptTextIcon, Trash2, UserIcon, } from "lucide-react"; import { isExternalCourse } from "@/lib/course-external"; import { + CourseInvoicesButton, DashboardFormMediaSplit, DashboardFormSectionLayout, DashboardPage, @@ -150,13 +150,6 @@ export default function CourseDetailPage() { }, ); - const { data: invoiceAccess } = api.invoices.canManageCourseInvoices.useQuery( - { courseId }, - { - enabled: !!courseId && !!session?.user && activeTab === "participants", - }, - ); - const approveMutation = api.courses.approve.useMutation({ onSuccess: () => { void refetchCourse(); @@ -468,6 +461,7 @@ export default function CourseDetailPage() { ]} actions={
+ {canEdit && ( )} - {invoiceAccess?.canManage && invoiceAccess.invoicingEnabled && ( - - - Rechnungen - - )} {canAddRegistrations && (
+ {canEdit && editingStatus ? (
toggleSelection(row.original.id)} + aria-label={`${row.original.title} auswählen`} + className="text-primary focus:ring-primary h-4 w-4 rounded border-gray-300" + /> + ), + }), + ] + : []; + + return column.columns([ + ...select, + column.accessor((course) => course.title, { + id: "title", + header: "Titel", + enableColumnFilter: false, + meta: { alwaysVisible: true }, + cell: ({ row }) => ( + + {row.original.title} + + ), + }), + column.accessor((course) => course.startDate, { + id: "startDate", + header: "Zeitraum", + enableColumnFilter: false, + meta: { cellClassName: "whitespace-nowrap tabular-nums" }, + cell: ({ row }) => { + const start = new Date(row.original.startDate); + const end = new Date(row.original.endDate); + const format = (date: Date) => + date.toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + return start.getTime() === end.getTime() + ? format(start) + : `${format(start)} – ${format(end)}`; + }, + }), + column.accessor((course) => course.location?.city ?? "", { + id: "location", + header: "Ort", + enableSorting: false, + enableColumnFilter: false, + cell: ({ getValue }) => getValue() || "–", + }), + column.accessor((course) => courseTypeLabels[course.courseType], { + id: "courseType", + header: "Kursart", + enableSorting: false, + enableColumnFilter: false, + }), + column.accessor((course) => course.bezirkId ?? "", { + id: "district", + header: "Bezirk", + enableSorting: false, + meta: { + align: "center", + label: "Bezirk", + filterVariant: "set", + filterOptions: bezirkColumnOptions, + }, + cell: ({ row }) => { + const number = row.original.bezirk?.number; + if (!number) return "–"; + return ( + + {number} + + ); + }, + }), + column.accessor((course) => course._count.participants, { + id: "participants", + header: "Teiln.", + enableSorting: false, + enableColumnFilter: false, + meta: { + align: "right", + label: "Teilnehmerzahl", + cellClassName: "tabular-nums whitespace-nowrap", + }, + cell: ({ row }) => + row.original.maxParticipants + ? `${row.original._count.participants} / ${row.original.maxParticipants}` + : String(row.original._count.participants), + }), + column.accessor( + (course) => (course.registrationOpen ? "Offen" : "Geschlossen"), + { + id: "registration", + header: "Anmeldung", + enableSorting: false, + enableColumnFilter: false, + cell: ({ row }) => ( + + {row.original.registrationOpen ? "Offen" : "Geschlossen"} + + ), + }, + ), + column.accessor((course) => course.status, { + id: "status", + header: "Status", + meta: { filterVariant: "set", filterOptions: statusColumnOptions }, + cell: ({ row }) => , + }), + column.accessor((course) => course.createdBy?.displayName ?? "", { + id: "createdBy", + header: "Erstellt von", + enableSorting: false, + enableColumnFilter: false, + cell: ({ getValue }) => getValue() || "–", + }), + column.accessor((course) => course.createdAt, { + id: "createdAt", + header: "Erstellt am", + enableColumnFilter: false, + meta: { cellClassName: "whitespace-nowrap tabular-nums" }, + cell: ({ getValue }) => + new Date(getValue()).toLocaleDateString("de-DE"), + }), + column.display({ + id: "actions", + header: "Aktionen", + meta: { align: "right", label: "Aktionen" }, + cell: ({ row }) => ( +
+ + + + + + +
+ ), + }), + ]); + }, [ + selectionMode, + selectedIds, + statusColumnOptions, + bezirkColumnOptions, + toggleSelection, + ]); const selectAll = () => { if (data?.courses) { @@ -268,55 +551,84 @@ export default function DashboardCoursesList({}: DashboardCoursesListProps) { const selectClass = "dark:border-dark-border dark:bg-dark-background min-h-9 min-w-0 rounded-md border border-gray-200/90 bg-white px-2.5 py-1.5 text-sm text-gray-900 dark:text-dark-text"; + // In der Tabelle sitzen Status und Sortierung in den Spaltenköpfen — beides + // zusätzlich in der Leiste zu zeigen wären zwei Schalter für dieselbe Sache. const filterControlsRow = (
{scheduleSegment}
- -
- - -
+ {view === "table" ? null : ( + <> +
+ +
+
+ +
+
+ + +
+ + )}
); @@ -324,32 +636,45 @@ export default function DashboardCoursesList({}: DashboardCoursesListProps) {
{!selectionMode && (
-
-

- {isLoading ? ( - Liste wird geladen… - ) : data ? ( - <> - - {data.total} - {" "} - {data.total === 1 ? "Kurs" : "Kurse"} - {scheduleFilter === "active" && " · aktuell & geplant"} - {scheduleFilter === "past" && " · vergangen"} - {scheduleFilter === "all" && " · alle Zeiträume"} - - ) : null} -

-
{filterControlsRow}
- + {/* Zählung und Ansichtsschalter oben, die Filter darunter über die + volle Breite: die Selects haben feste Breiten und drängeln sich in + einer gemeinsamen Zeile bei mittleren Fenstern gegenseitig weg. */} +
+
+

+ {isLoading ? ( + Liste wird geladen… + ) : data ? ( + <> + + {data.total} + {" "} + {data.total === 1 ? "Kurs" : "Kurse"} + {scheduleFilter === "active" && " · aktuell & geplant"} + {scheduleFilter === "past" && " · vergangen"} + {scheduleFilter === "all" && " · alle Zeiträume"} + + ) : null} +

+
+ + +
+
+ {filterControlsRow ? ( +
{filterControlsRow}
+ ) : null}
@@ -368,15 +693,21 @@ export default function DashboardCoursesList({}: DashboardCoursesListProps) { ) : null}

- +
+ + +
@@ -387,7 +718,7 @@ export default function DashboardCoursesList({}: DashboardCoursesListProps) { > - Zeitraum, Status, Sortierung + Zeitraum, Status, Bezirk, Sortierung {adjustedFilterCount > 0 ? ( @@ -485,124 +816,190 @@ export default function DashboardCoursesList({}: DashboardCoursesListProps) { )} {/* Loading State */} - {isLoading && ( -
- {[...Array(6)].map((_, i) => ( -
- ))} -
- )} - - {/* Courses Grid */} - {!isLoading && data?.courses && data.courses.length > 0 && ( -
- {data.courses.map((course) => ( -
- {selectionMode && ( + {view === "table" ? ( + course.id} + isLoading={isLoading} + rowNoun={["Kurs", "Kurse"]} + searchPlaceholder="Titel oder Ort suchen…" + pageSizeOptions={[25, 50, 100]} + emptyState={ + <> + +

+ Keine Kurse gefunden +

+

+ Passe Zeitraum, Status oder Suche an. +

+ + } + sorting={sorting} + onSortingChange={(updater) => { + setSorting( + typeof updater === "function" ? updater(sorting) : updater, + ); + setPage(1); + }} + manualSorting + columnFilters={columnFilters} + onColumnFiltersChange={(updater) => { + const next = + typeof updater === "function" ? updater(columnFilters) : updater; + const read = (id: string) => + next.find((filter) => filter.id === id)?.value as + string[] | undefined; + // Der Server kennt nur einen Status je Abfrage; die Mehrfachauswahl + // der Spalte wird darum auf den ersten Wert eingedampft. Bezirke + // nimmt er dagegen als Liste entgegen. + const status = read("status"); + setStatusFilter( + status?.length ? (status[0] as ContentStatus) : "all", + ); + setBezirkFilter(read("district") ?? []); + setPage(1); + }} + manualFiltering + search={search} + onSearchChange={(value) => { + setSearch(value); + setPage(1); + }} + pagination={pagination} + onPaginationChange={(updater) => { + const next = + typeof updater === "function" ? updater(pagination) : updater; + setTablePageSize(next.pageSize); + setPage(next.pageIndex + 1); + }} + manualPagination + rowCount={data?.total ?? 0} + /> + ) : ( + <> + {isLoading && ( +
+ {[...Array(6)].map((_, i) => (
toggleSelection(course.id)} - > -
+ key={i} + className="dark:border-dark-border dark:bg-dark-surface h-52 animate-pulse rounded-lg border border-gray-200/70 bg-gray-100" + /> + ))} +
+ )} + + {/* Courses Grid */} + {!isLoading && data?.courses && data.courses.length > 0 && ( +
+ {data.courses.map((course) => ( +
+ {selectionMode && (
toggleSelection(course.id)} > - {selectedIds.has(course.id) && ( - - )} +
+
+ {selectedIds.has(course.id) && ( + + )} +
+
-
+ )} +
- )} - + ))}
- ))} -
- )} + )} - {/* Empty State */} - {!isLoading && data?.courses && data.courses.length === 0 && ( -
- -

- Keine Kurse gefunden -

-

- {statusFilter !== "all" - ? "Für diese Statusfilter gibt es keine Treffer." - : scheduleFilter === "active" - ? "Keine Kurse mehr im aktuellen Zeitraum. Versuche „Alle Zeiträume“ oder „Vergangen“, oder lege einen neuen Kurs an." - : scheduleFilter === "past" - ? "Keine vergangenen Kurse gefunden." - : "Es gibt noch keine Kurse."} -

-
- )} + {/* Empty State */} + {!isLoading && data?.courses && data.courses.length === 0 && ( +
+ +

+ Keine Kurse gefunden +

+

+ {statusFilter !== "all" + ? "Für diese Statusfilter gibt es keine Treffer." + : scheduleFilter === "active" + ? "Keine Kurse mehr im aktuellen Zeitraum. Versuche „Alle Zeiträume“ oder „Vergangen“, oder lege einen neuen Kurs an." + : scheduleFilter === "past" + ? "Keine vergangenen Kurse gefunden." + : "Es gibt noch keine Kurse."} +

+
+ )} - {/* Pagination */} - {data && data.pages > 1 && ( -
- + {/* Pagination */} + {view === "cards" && data && data.pages > 1 && ( +
+ - - Seite {page} von {data.pages} - + + Seite {page} von {data.pages} + - -
+ +
+ )} + + {/* Delete Confirmation Modal */} + )} - {/* Delete Confirmation Modal */} {showDeleteConfirm && ( diff --git a/src/app/_components/dashboard/dashboard-events-list.tsx b/src/app/_components/dashboard/dashboard-events-list.tsx index b420ff8c..a0618f1e 100644 --- a/src/app/_components/dashboard/dashboard-events-list.tsx +++ b/src/app/_components/dashboard/dashboard-events-list.tsx @@ -1,16 +1,36 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import { api } from "@/trpc/react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { api, type RouterOutputs } from "@/trpc/react"; import { usePermissions } from "@/lib/use-permissions"; import type { PermissionKey } from "@/lib/permissions"; import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { capitalizeFirstLetter, cn } from "@/lib/utils"; +import { getDistrictColor } from "@/lib/district-color"; +import { eventPath } from "@/lib/slug"; import DashboardEventCard from "./dashboard-event-card"; +import { + DashboardListViewToggle, + useDashboardListView, +} from "./dashboard-list-view"; +import { CONTENT_STATUS_OPTIONS, ContentStatusBadge } from "./content-status"; +import { + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; +import type { + ColumnFiltersState, + PaginationState, + SortingState, +} from "@tanstack/react-table"; import type { ContentStatus } from "~/generated/prisma/enums"; import { useToast } from "@/app/_components/ui/toast"; import { ArrowDownIcon, ArrowLeftIcon, + ExternalLinkIcon, ArrowRightIcon, ArrowUpIcon, BanIcon, @@ -29,10 +49,20 @@ import { ScrollableModalBody, ScrollableModalFooter, } from "@/app/_components/ui/scrollable-modal"; -import { cn } from "@/lib/utils"; type DashboardEventsListProps = Record; +type DashboardEvent = + RouterOutputs["events"]["getDashboardEvents"]["events"][number]; + +/** Die Standardordnung der Liste — auch das Ziel des dritten Sortierklicks. */ +const DEFAULT_SORTING = { id: "eventDate", desc: false } as const; + +/** Die Spalten, nach denen der Server sortieren kann. */ +type TableSortColumn = "title" | "eventDate" | "status" | "createdAt"; + +const column = createDataTableColumnHelper(); + type DashboardEventsScheduleFilter = "active" | "all" | "past"; const scheduleFilters: { @@ -71,22 +101,39 @@ export default function DashboardEventsList({}: DashboardEventsListProps) { ); const [scheduleFilter, setScheduleFilter] = useState("active"); - const [sortBy, setSortBy] = useState< - "eventDate" | "title" | "createdAt" | "status" - >("eventDate"); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + // Die Sortierung selbst ist der Zustand — eine leere Sortierung ist der + // dritte Klick auf einen Spaltenkopf und bedeutet "wieder Standardordnung". + // Aus ihr werden Spalte und Richtung für Abfrage und Kartenansicht abgeleitet. + const [sorting, setSorting] = useState([DEFAULT_SORTING]); + const activeSort = sorting[0] ?? DEFAULT_SORTING; + const sortBy = activeSort.id as TableSortColumn; + const sortOrder = activeSort.desc ? "desc" : "asc"; + + const setSortBy = (next: TableSortColumn) => + setSorting([{ id: next, desc: activeSort.desc }]); + /** Bezirks-Set-Filter; leer heißt "alle Bezirke". */ + const [bezirkFilter, setBezirkFilter] = useState([]); const [page, setPage] = useState(1); const [filtersOpen, setFiltersOpen] = useState(false); + const [view, setView] = useDashboardListView("dashboard-events-view"); + // Nur die Tabellenansicht sucht: in der Kartenansicht gäbe es kein Feld dazu, + // und ein Filter ohne sichtbaren Schalter ist ein Filter, den niemand findet. + const [search, setSearch] = useState(""); + const [tablePageSize, setTablePageSize] = useState(25); const [selectionMode, setSelectionMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showCancelConfirm, setShowCancelConfirm] = useState(false); const [showStatusChange, setShowStatusChange] = useState(false); const [newStatus, setNewStatus] = useState(null); - const limit = 12; + // Karten füllen ein Raster, Tabellenzeilen eine Seite — beide brauchen + // eine andere Seitengröße. + const limit = view === "table" ? tablePageSize : 12; const utils = api.useUtils(); + const { data: bezirke } = api.bezirke.getAll.useQuery(); + const { data, isLoading, error } = api.events.getDashboardEvents.useQuery({ page, limit, @@ -94,6 +141,8 @@ export default function DashboardEventsList({}: DashboardEventsListProps) { schedule: scheduleFilter, sortBy, sortOrder, + bezirkId: bezirkFilter.length ? bezirkFilter : undefined, + search: view === "table" && search ? search : undefined, }); useEffect(() => { @@ -176,20 +225,61 @@ export default function DashboardEventsList({}: DashboardEventsListProps) { return filter.value !== "DRAFT"; }); + const bezirkColumnOptions = useMemo( + () => + (bezirke ?? []).map((bezirk) => ({ + value: bezirk.id, + label: + `${bezirk.number} · ${bezirk.shortName ?? bezirk.name ?? ""}`.trim(), + })), + [bezirke], + ); + + const statusColumnOptions = useMemo( + () => + CONTENT_STATUS_OPTIONS.filter( + (option) => hasApprovePermission || option.value !== "DRAFT", + ), + [hasApprovePermission], + ); + const adjustedFilterCount = useMemo(() => { return ( (statusFilter !== "all" ? 1 : 0) + + (bezirkFilter.length > 0 ? 1 : 0) + (scheduleFilter !== "active" ? 1 : 0) + (sortBy !== "eventDate" || sortOrder !== "asc" ? 1 : 0) ); - }, [statusFilter, scheduleFilter, sortBy, sortOrder]); + }, [statusFilter, bezirkFilter, scheduleFilter, sortBy, sortOrder]); + + /** Beim Wechsel neu aufsetzen: die Seitengröße unterscheidet sich. */ + const handleViewChange = (next: "cards" | "table") => { + setView(next); + setPage(1); + if (next === "cards") setSearch(""); + }; + + const columnFilters: ColumnFiltersState = useMemo( + () => [ + ...(statusFilter === "all" + ? [] + : [{ id: "status", value: [statusFilter] }]), + ...(bezirkFilter.length ? [{ id: "district", value: bezirkFilter }] : []), + ], + [statusFilter, bezirkFilter], + ); + + const pagination: PaginationState = useMemo( + () => ({ pageIndex: page - 1, pageSize: limit }), + [page, limit], + ); const toggleSortOrder = () => { - setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); + setSorting([{ id: sortBy, desc: sortOrder === "asc" }]); setPage(1); }; - const toggleSelection = (id: string) => { + const toggleSelection = useCallback((id: string) => { setSelectedIds((prev) => { const newSet = new Set(prev); if (newSet.has(id)) { @@ -199,7 +289,157 @@ export default function DashboardEventsList({}: DashboardEventsListProps) { } return newSet; }); - }; + }, []); + + const columns = useMemo[]>(() => { + const select = selectionMode + ? [ + column.display({ + id: "select", + header: "", + meta: { alwaysVisible: true, headerClassName: "w-10" }, + cell: ({ row }) => ( + toggleSelection(row.original.id)} + aria-label={`${row.original.title} auswählen`} + className="text-primary focus:ring-primary h-4 w-4 rounded border-gray-300" + /> + ), + }), + ] + : []; + + return column.columns([ + ...select, + column.accessor((event) => event.title, { + id: "title", + header: "Titel", + enableColumnFilter: false, + meta: { alwaysVisible: true }, + cell: ({ row }) => ( +
+ + {row.original.title} + + {row.original.cancelled && ( + + Abgesagt + + )} +
+ ), + }), + column.accessor((event) => event.eventDate, { + id: "eventDate", + header: "Datum", + enableColumnFilter: false, + meta: { cellClassName: "whitespace-nowrap tabular-nums" }, + cell: ({ getValue }) => + new Date(getValue()).toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }), + }), + column.accessor((event) => event.location?.city ?? "", { + id: "location", + header: "Ort", + enableSorting: false, + enableColumnFilter: false, + cell: ({ getValue }) => getValue() || "–", + }), + column.accessor((event) => event.category, { + id: "category", + header: "Kategorie", + enableSorting: false, + enableColumnFilter: false, + cell: ({ getValue }) => capitalizeFirstLetter(getValue()), + }), + column.accessor((event) => event.bezirkId ?? "", { + id: "district", + header: "Bezirk", + enableSorting: false, + meta: { + align: "center", + label: "Bezirk", + filterVariant: "set", + filterOptions: bezirkColumnOptions, + }, + cell: ({ row }) => { + const number = row.original.bezirk?.number; + if (!number) return "–"; + return ( + + {number} + + ); + }, + }), + column.accessor((event) => event.status, { + id: "status", + header: "Status", + meta: { filterVariant: "set", filterOptions: statusColumnOptions }, + cell: ({ row }) => , + }), + column.accessor((event) => event.createdBy?.displayName ?? "", { + id: "createdBy", + header: "Erstellt von", + enableSorting: false, + enableColumnFilter: false, + cell: ({ getValue }) => getValue() || "–", + }), + column.accessor((event) => event.createdAt, { + id: "createdAt", + header: "Erstellt am", + enableColumnFilter: false, + meta: { cellClassName: "whitespace-nowrap tabular-nums" }, + cell: ({ getValue }) => + new Date(getValue()).toLocaleDateString("de-DE"), + }), + column.display({ + id: "actions", + header: "Aktionen", + meta: { align: "right", label: "Aktionen" }, + cell: ({ row }) => ( +
+ + + + + + +
+ ), + }), + ]); + }, [ + selectionMode, + selectedIds, + statusColumnOptions, + bezirkColumnOptions, + toggleSelection, + ]); const selectAll = () => { if (data?.events) { @@ -286,55 +526,84 @@ export default function DashboardEventsList({}: DashboardEventsListProps) { const selectClass = "dark:border-dark-border dark:bg-dark-background min-h-9 min-w-0 rounded-md border border-gray-200/90 bg-white px-2.5 py-1.5 text-sm text-gray-900 dark:text-dark-text"; + // In der Tabelle sitzen Status und Sortierung in den Spaltenköpfen — beides + // zusätzlich in der Leiste zu zeigen wären zwei Schalter für dieselbe Sache. const filterControlsRow = (
{scheduleSegment}
- -
- - -
+ {view === "table" ? null : ( + <> +
+ +
+
+ +
+
+ + +
+ + )}
); @@ -342,32 +611,45 @@ export default function DashboardEventsList({}: DashboardEventsListProps) {
{!selectionMode && (
-
-

- {isLoading ? ( - Liste wird geladen… - ) : data ? ( - <> - - {data.total} - {" "} - {data.total === 1 ? "Termin" : "Termine"} - {scheduleFilter === "active" && " · aktuell & geplant"} - {scheduleFilter === "past" && " · vergangen"} - {scheduleFilter === "all" && " · alle Zeiträume"} - - ) : null} -

-
{filterControlsRow}
- + {/* Zählung und Ansichtsschalter oben, die Filter darunter über die + volle Breite: die Selects haben feste Breiten und drängeln sich in + einer gemeinsamen Zeile bei mittleren Fenstern gegenseitig weg. */} +
+
+

+ {isLoading ? ( + Liste wird geladen… + ) : data ? ( + <> + + {data.total} + {" "} + {data.total === 1 ? "Termin" : "Termine"} + {scheduleFilter === "active" && " · aktuell & geplant"} + {scheduleFilter === "past" && " · vergangen"} + {scheduleFilter === "all" && " · alle Zeiträume"} + + ) : null} +

+
+ + +
+
+ {filterControlsRow ? ( +
{filterControlsRow}
+ ) : null}
@@ -386,15 +668,21 @@ export default function DashboardEventsList({}: DashboardEventsListProps) { ) : null}

- +
+ + +
@@ -405,7 +693,7 @@ export default function DashboardEventsList({}: DashboardEventsListProps) { > - Zeitraum, Status, Sortierung + Zeitraum, Status, Bezirk, Sortierung {adjustedFilterCount > 0 ? ( @@ -513,105 +801,170 @@ export default function DashboardEventsList({}: DashboardEventsListProps) {
)} - {isLoading && ( -
- {[...Array(6)].map((_, i) => ( -
- ))} -
- )} - - {!isLoading && data?.events && data.events.length > 0 && ( -
- {data.events.map((event) => ( -
- {selectionMode && ( + {view === "table" ? ( + event.id} + isLoading={isLoading} + rowNoun={["Termin", "Termine"]} + searchPlaceholder="Titel oder Ort suchen…" + pageSizeOptions={[25, 50, 100]} + emptyState={ + <> + +

+ Keine Termine gefunden +

+

+ Passe Zeitraum, Status oder Suche an. +

+ + } + sorting={sorting} + onSortingChange={(updater) => { + setSorting( + typeof updater === "function" ? updater(sorting) : updater, + ); + setPage(1); + }} + manualSorting + columnFilters={columnFilters} + onColumnFiltersChange={(updater) => { + const next = + typeof updater === "function" ? updater(columnFilters) : updater; + const read = (id: string) => + next.find((filter) => filter.id === id)?.value as + string[] | undefined; + // Der Server kennt nur einen Status je Abfrage; die Mehrfachauswahl + // der Spalte wird darum auf den ersten Wert eingedampft. Bezirke + // nimmt er dagegen als Liste entgegen. + const status = read("status"); + setStatusFilter( + status?.length ? (status[0] as ContentStatus) : "all", + ); + setBezirkFilter(read("district") ?? []); + setPage(1); + }} + manualFiltering + search={search} + onSearchChange={(value) => { + setSearch(value); + setPage(1); + }} + pagination={pagination} + onPaginationChange={(updater) => { + const next = + typeof updater === "function" ? updater(pagination) : updater; + setTablePageSize(next.pageSize); + setPage(next.pageIndex + 1); + }} + manualPagination + rowCount={data?.total ?? 0} + /> + ) : ( + <> + {isLoading && ( +
+ {[...Array(6)].map((_, i) => (
toggleSelection(event.id)} - > -
+ key={i} + className="dark:border-dark-border dark:bg-dark-surface h-52 animate-pulse rounded-lg border border-gray-200/70 bg-gray-100" + /> + ))} +
+ )} + + {!isLoading && data?.events && data.events.length > 0 && ( +
+ {data.events.map((event) => ( +
+ {selectionMode && (
toggleSelection(event.id)} > - {selectedIds.has(event.id) && ( - - )} +
+
+ {selectedIds.has(event.id) && ( + + )} +
+
-
+ )} +
- )} - + ))}
- ))} -
- )} + )} - {!isLoading && data?.events && data.events.length === 0 && ( -
- -

- Keine Termine gefunden -

-

- {statusFilter !== "all" - ? "Für diese Statusfilter gibt es keine Treffer." - : scheduleFilter === "active" - ? "Keine Termine mehr im aktuellen Zeitraum. Versuche „Alle Zeiträume“ oder „Vergangen“, oder lege einen neuen Termin an." - : scheduleFilter === "past" - ? "Keine vergangenen Termine gefunden." - : "Es gibt noch keine Termine."} -

-
- )} + {!isLoading && data?.events && data.events.length === 0 && ( +
+ +

+ Keine Termine gefunden +

+

+ {statusFilter !== "all" + ? "Für diese Statusfilter gibt es keine Treffer." + : scheduleFilter === "active" + ? "Keine Termine mehr im aktuellen Zeitraum. Versuche „Alle Zeiträume“ oder „Vergangen“, oder lege einen neuen Termin an." + : scheduleFilter === "past" + ? "Keine vergangenen Termine gefunden." + : "Es gibt noch keine Termine."} +

+
+ )} - {data && data.pages > 1 && ( -
- - - - Seite {page} von {data.pages} - + {view === "cards" && data && data.pages > 1 && ( +
+ - -
+ + Seite {page} von {data.pages} + + + +
+ )} + )} {showDeleteConfirm && ( diff --git a/src/app/_components/dashboard/dashboard-list-view.tsx b/src/app/_components/dashboard/dashboard-list-view.tsx new file mode 100644 index 00000000..8b3bccfa --- /dev/null +++ b/src/app/_components/dashboard/dashboard-list-view.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useCallback, useSyncExternalStore } from "react"; +import { LayoutGridIcon, TableIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export type DashboardListView = "cards" | "table"; + +const VIEWS: { + value: DashboardListView; + label: string; + title: string; + icon: typeof TableIcon; +}[] = [ + { + value: "cards", + label: "Karten", + title: "Kartenansicht", + icon: LayoutGridIcon, + }, + { + value: "table", + label: "Tabelle", + title: "Tabellenansicht", + icon: TableIcon, + }, +]; + +/** + * Abonnenten der gespeicherten Ansicht. `localStorage` meldet Änderungen nur an + * *andere* Tabs, nicht an den schreibenden — die Listen dieses Tabs brauchen + * deshalb einen eigenen Verteiler. + */ +const listeners = new Set<() => void>(); + +function subscribe(onChange: () => void) { + listeners.add(onChange); + window.addEventListener("storage", onChange); + return () => { + listeners.delete(onChange); + window.removeEventListener("storage", onChange); + }; +} + +function readView(storageKey: string): DashboardListView | null { + try { + const stored = window.localStorage.getItem(storageKey); + return stored === "cards" || stored === "table" ? stored : null; + } catch { + // Privater Modus oder blockierte Site-Daten: dann eben die Vorgabe. + return null; + } +} + +/** + * Kartenraster oder Tabelle — die Wahl bleibt pro Liste gespeichert, weil sie + * zur Arbeitsweise gehört und nicht zur einzelnen Sitzung: wer die Termine + * lieber als Tabelle pflegt, will sie beim nächsten Aufruf wieder so sehen. + * + * `useSyncExternalStore` statt eines Effekts: der Server kennt `localStorage` + * nicht und liefert immer die Vorgabe, und React weiß dadurch selbst, dass die + * erste Client-Ausgabe davon abweichen darf. + */ +export function useDashboardListView( + storageKey: string, + fallback: DashboardListView = "cards", +): [DashboardListView, (next: DashboardListView) => void] { + const view = useSyncExternalStore( + subscribe, + () => readView(storageKey) ?? fallback, + () => fallback, + ); + + const update = useCallback( + (next: DashboardListView) => { + try { + window.localStorage.setItem(storageKey, next); + } catch { + // Nicht speicherbar — dann bleibt es bei der Vorgabe. + } + listeners.forEach((listener) => listener()); + }, + [storageKey], + ); + + return [view, update]; +} + +/** Umschalter zwischen Kartenraster und Tabelle. */ +export function DashboardListViewToggle({ + view, + onChange, + className, +}: { + view: DashboardListView; + onChange: (next: DashboardListView) => void; + className?: string; +}) { + return ( +
+ {VIEWS.map((entry) => { + const Icon = entry.icon; + const active = view === entry.value; + return ( + + ); + })} +
+ ); +} diff --git a/src/app/_components/dashboard/dashboard-posts-list.tsx b/src/app/_components/dashboard/dashboard-posts-list.tsx index b7d30e7f..702e5b08 100644 --- a/src/app/_components/dashboard/dashboard-posts-list.tsx +++ b/src/app/_components/dashboard/dashboard-posts-list.tsx @@ -1,17 +1,37 @@ "use client"; -import { useState } from "react"; -import { api } from "@/trpc/react"; +import { useCallback, useMemo, useState } from "react"; +import { api, type RouterOutputs } from "@/trpc/react"; import { usePermissions } from "@/lib/use-permissions"; import type { PermissionKey } from "@/lib/permissions"; import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { getDistrictColor } from "@/lib/district-color"; +import { postPath } from "@/lib/slug"; import DashboardPostCard from "./dashboard-post-card"; +import { + DashboardListViewToggle, + useDashboardListView, +} from "./dashboard-list-view"; +import { CONTENT_STATUS_OPTIONS, ContentStatusBadge } from "./content-status"; +import { + DataTable, + createDataTableColumnHelper, + type DataTableColumn, +} from "@/app/_components/ui/data-table"; +import type { + ColumnFiltersState, + PaginationState, + SortingState, +} from "@tanstack/react-table"; import type { ContentStatus, PostCategory } from "~/generated/prisma/client"; import { useToast } from "@/app/_components/ui/toast"; import { Button, Select } from "@/app/_components/ui"; import { ArrowDownIcon, ArrowLeftIcon, + ExternalLinkIcon, + PinIcon, ArrowRightIcon, ArrowUpIcon, CheckIcon, @@ -32,6 +52,17 @@ import { cn } from "@/lib/utils"; type DashboardPostsListProps = Record; +type DashboardPost = + RouterOutputs["posts"]["getDashboardPosts"]["posts"][number]; + +/** Die Standardordnung der Liste — auch das Ziel des dritten Sortierklicks. */ +const DEFAULT_SORTING = { id: "createdAt", desc: true } as const; + +/** Die Spalten, nach denen der Server sortieren kann. */ +type TableSortColumn = "title" | "publishedAt" | "status" | "createdAt"; + +const column = createDataTableColumnHelper(); + const statusFilters: { value: ContentStatus | "all"; label: string }[] = [ { value: "all", label: "Alle" }, { value: "DRAFT", label: "Entwürfe" }, @@ -72,21 +103,38 @@ export default function DashboardPostsList({}: DashboardPostsListProps) { const [categoryFilter, setCategoryFilter] = useState( "all", ); - const [sortBy, setSortBy] = useState< - "publishedAt" | "title" | "createdAt" | "status" - >("createdAt"); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); + // Die Sortierung selbst ist der Zustand — eine leere Sortierung ist der + // dritte Klick auf einen Spaltenkopf und bedeutet "wieder Standardordnung". + // Aus ihr werden Spalte und Richtung für Abfrage und Kartenansicht abgeleitet. + const [sorting, setSorting] = useState([DEFAULT_SORTING]); + const activeSort = sorting[0] ?? DEFAULT_SORTING; + const sortBy = activeSort.id as TableSortColumn; + const sortOrder = activeSort.desc ? "desc" : "asc"; + + const setSortBy = (next: TableSortColumn) => + setSorting([{ id: next, desc: activeSort.desc }]); + /** Bezirks-Set-Filter; leer heißt "alle Bezirke". */ + const [bezirkFilter, setBezirkFilter] = useState([]); const [page, setPage] = useState(1); const [filtersOpen, setFiltersOpen] = useState(false); + const [view, setView] = useDashboardListView("dashboard-posts-view"); + // Nur die Tabellenansicht sucht: in der Kartenansicht gäbe es kein Feld dazu, + // und ein Filter ohne sichtbaren Schalter ist ein Filter, den niemand findet. + const [search, setSearch] = useState(""); + const [tablePageSize, setTablePageSize] = useState(25); const [selectionMode, setSelectionMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showStatusChange, setShowStatusChange] = useState(false); const [newStatus, setNewStatus] = useState(null); - const limit = 12; + // Karten füllen ein Raster, Tabellenzeilen eine Seite — beide brauchen + // eine andere Seitengröße. + const limit = view === "table" ? tablePageSize : 12; const utils = api.useUtils(); + const { data: bezirke } = api.bezirke.getAll.useQuery(); + const { data, isLoading, error } = api.posts.getDashboardPosts.useQuery({ page, limit, @@ -94,6 +142,8 @@ export default function DashboardPostsList({}: DashboardPostsListProps) { category: categoryFilter === "all" ? undefined : categoryFilter, sortBy, sortOrder, + bezirkId: bezirkFilter.length ? bezirkFilter : undefined, + search: view === "table" && search ? search : undefined, }); const bulkDeleteMutation = api.posts.bulkDelete.useMutation({ @@ -155,14 +205,69 @@ export default function DashboardPostsList({}: DashboardPostsListProps) { const adjustedFilterCount = (statusFilter !== "all" ? 1 : 0) + (categoryFilter !== "all" ? 1 : 0) + + (bezirkFilter.length > 0 ? 1 : 0) + (sortBy !== "createdAt" ? 1 : 0); + /** Beim Wechsel neu aufsetzen: die Seitengröße unterscheidet sich. */ + const handleViewChange = (next: "cards" | "table") => { + setView(next); + setPage(1); + if (next === "cards") setSearch(""); + }; + + const bezirkColumnOptions = useMemo( + () => + (bezirke ?? []).map((bezirk) => ({ + value: bezirk.id, + label: + `${bezirk.number} · ${bezirk.shortName ?? bezirk.name ?? ""}`.trim(), + })), + [bezirke], + ); + + const statusColumnOptions = useMemo( + () => + CONTENT_STATUS_OPTIONS.filter( + (option) => hasApprovePermission || option.value !== "DRAFT", + ), + [hasApprovePermission], + ); + + const categoryColumnOptions = useMemo( + () => + categoryFilters + .filter((option) => option.value !== "all") + .map((option) => ({ + value: String(option.value), + label: option.label, + })), + [], + ); + + const columnFilters: ColumnFiltersState = useMemo( + () => [ + ...(statusFilter === "all" + ? [] + : [{ id: "status", value: [statusFilter] }]), + ...(categoryFilter === "all" + ? [] + : [{ id: "category", value: [categoryFilter] }]), + ...(bezirkFilter.length ? [{ id: "district", value: bezirkFilter }] : []), + ], + [statusFilter, categoryFilter, bezirkFilter], + ); + + const pagination: PaginationState = useMemo( + () => ({ pageIndex: page - 1, pageSize: limit }), + [page, limit], + ); + const toggleSortOrder = () => { - setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); + setSorting([{ id: sortBy, desc: sortOrder === "asc" }]); setPage(1); }; - const toggleSelection = (id: string) => { + const toggleSelection = useCallback((id: string) => { setSelectedIds((prev) => { const newSet = new Set(prev); if (newSet.has(id)) { @@ -172,7 +277,156 @@ export default function DashboardPostsList({}: DashboardPostsListProps) { } return newSet; }); - }; + }, []); + + const columns = useMemo[]>(() => { + const select = selectionMode + ? [ + column.display({ + id: "select", + header: "", + meta: { alwaysVisible: true, headerClassName: "w-10" }, + cell: ({ row }) => ( + toggleSelection(row.original.id)} + aria-label={`${row.original.title} auswählen`} + className="text-primary focus:ring-primary h-4 w-4 rounded border-gray-300" + /> + ), + }), + ] + : []; + + return column.columns([ + ...select, + column.accessor((post) => post.title, { + id: "title", + header: "Titel", + enableColumnFilter: false, + meta: { alwaysVisible: true }, + cell: ({ row }) => ( +
+ {row.original.pinned && ( + + )} +
+ + {row.original.title} + + {row.original.excerpt && ( +

+ {row.original.excerpt} +

+ )} +
+
+ ), + }), + column.accessor((post) => post.category, { + id: "category", + header: "Kategorie", + enableSorting: false, + meta: { filterVariant: "set", filterOptions: categoryColumnOptions }, + cell: ({ getValue }) => + categoryColumnOptions.find((option) => option.value === getValue()) + ?.label ?? getValue(), + }), + column.accessor((post) => post.bezirkId ?? "", { + id: "district", + header: "Bezirk", + enableSorting: false, + meta: { + align: "center", + label: "Bezirk", + filterVariant: "set", + filterOptions: bezirkColumnOptions, + }, + cell: ({ row }) => { + const number = row.original.bezirk?.number; + if (!number) return "–"; + return ( + + {number} + + ); + }, + }), + column.accessor((post) => post.status, { + id: "status", + header: "Status", + meta: { filterVariant: "set", filterOptions: statusColumnOptions }, + cell: ({ row }) => , + }), + column.accessor((post) => post.publishedAt, { + id: "publishedAt", + header: "Veröffentlicht", + enableColumnFilter: false, + meta: { cellClassName: "whitespace-nowrap tabular-nums" }, + cell: ({ getValue }) => { + const value = getValue(); + return value ? new Date(value).toLocaleDateString("de-DE") : "–"; + }, + }), + column.accessor((post) => post.createdBy?.displayName ?? "", { + id: "createdBy", + header: "Erstellt von", + enableSorting: false, + enableColumnFilter: false, + cell: ({ getValue }) => getValue() || "–", + }), + column.accessor((post) => post.createdAt, { + id: "createdAt", + header: "Erstellt am", + enableColumnFilter: false, + meta: { cellClassName: "whitespace-nowrap tabular-nums" }, + cell: ({ getValue }) => + new Date(getValue()).toLocaleDateString("de-DE"), + }), + column.display({ + id: "actions", + header: "Aktionen", + meta: { align: "right", label: "Aktionen" }, + cell: ({ row }) => ( +
+ + + + + + +
+ ), + }), + ]); + }, [ + selectionMode, + selectedIds, + statusColumnOptions, + categoryColumnOptions, + bezirkColumnOptions, + toggleSelection, + ]); const selectAll = () => { if (data?.posts) { @@ -230,72 +484,97 @@ export default function DashboardPostsList({}: DashboardPostsListProps) { const selectClass = "dark:border-dark-border dark:bg-dark-background min-h-9 min-w-0 rounded-md border border-gray-200/90 bg-white px-2.5 py-1.5 text-sm text-gray-900 dark:text-dark-text"; - const filterControlsRow = ( -
- - -
- - + // In der Tabelle sitzen Status, Kategorie und Sortierung in den Spaltenköpfen + // — beides zugleich wären zwei Schalter für dieselbe Sache. + const filterControlsRow = + view === "table" ? null : ( +
+
+ +
+
+ +
+
+ +
+
+ + +
-
- ); + ); return (
@@ -315,15 +594,21 @@ export default function DashboardPostsList({}: DashboardPostsListProps) { ) : null}

- +
+ + +
{filterControlsRow}
@@ -341,18 +626,24 @@ export default function DashboardPostsList({}: DashboardPostsListProps) { ) : null}

- +
+ + +
-
+
+ {/* Pagination */} + {view === "cards" && data && data.pages > 1 && ( +
+ - - Seite {page} von {data.pages} - + + Seite {page} von {data.pages} + - -
+ +
+ )} + + {/* Delete Confirmation Modal */} + )} - {/* Delete Confirmation Modal */} {showDeleteConfirm && ( diff --git a/src/app/_components/dashboard/index.ts b/src/app/_components/dashboard/index.ts index 541a026e..deb17678 100644 --- a/src/app/_components/dashboard/index.ts +++ b/src/app/_components/dashboard/index.ts @@ -46,3 +46,14 @@ export { } from "./new-location-form"; export { SocialLinksEditor, cleanSocialLinks } from "./social-links-editor"; export { CourseInvoicesButton } from "./course-invoices-button"; +export { + DashboardListViewToggle, + useDashboardListView, + type DashboardListView, +} from "./dashboard-list-view"; +export { + ContentStatusBadge, + CONTENT_STATUS_LABELS, + CONTENT_STATUS_BADGE_CLASSES, + CONTENT_STATUS_OPTIONS, +} from "./content-status"; diff --git a/src/app/_components/ui/README.md b/src/app/_components/ui/README.md index 68736224..eb50a4b1 100644 --- a/src/app/_components/ui/README.md +++ b/src/app/_components/ui/README.md @@ -287,6 +287,21 @@ nothing is worse than no control. stacked card below `md`; the toolbar and pager stay. Without it the table scrolls horizontally, which is fine up to about five columns. +**Card grid or table.** The overview lists (Termine, Kurse, Beiträge) offer both +and remember the choice per list: + +```tsx +import { + DashboardListViewToggle, + useDashboardListView, +} from "@/app/_components/dashboard"; + +const [view, setView] = useDashboardListView("dashboard-events-view"); +``` + +In table mode the toolbar's status and sort selects are hidden — those live in +the column headers there, and showing both would be two controls for one thing. + ## Form Example ```tsx diff --git a/src/server/api/routers/courses.ts b/src/server/api/routers/courses.ts index 6c0cf1b3..86cb839a 100644 --- a/src/server/api/routers/courses.ts +++ b/src/server/api/routers/courses.ts @@ -425,6 +425,10 @@ export const coursesRouter = createTRPCRouter({ .enum(["startDate", "title", "createdAt", "status"]) .default("startDate"), sortOrder: z.enum(["asc", "desc"]).default("asc"), + /** Set-Filter über Bezirke; leer heißt "alle". */ + bezirkId: z.array(z.string()).optional(), + /** Freitext über Titel und Ort, für die Suche der Tabellenansicht. */ + search: z.string().trim().max(200).optional(), }), ) .query(async ({ ctx, input }) => { @@ -507,10 +511,36 @@ export const coursesRouter = createTRPCRouter({ } : { endDate: { lt: new Date() } }; - const whereWithSchedule: Prisma.CourseWhereInput = - input.schedule === "all" - ? (where as Prisma.CourseWhereInput) - : { AND: [where as Prisma.CourseWhereInput, scheduleWhere] }; + const searchWhere: Prisma.CourseWhereInput | null = input.search + ? { + OR: [ + { title: { contains: input.search, mode: "insensitive" } }, + { + location: { + city: { contains: input.search, mode: "insensitive" }, + }, + }, + { + location: { + name: { contains: input.search, mode: "insensitive" }, + }, + }, + ], + } + : null; + + const whereWithSchedule: Prisma.CourseWhereInput = { + AND: [ + where as Prisma.CourseWhereInput, + ...(input.schedule === "all" ? [] : [scheduleWhere]), + // Zusätzlich zum Bezirks-Scope, nicht statt seiner: wer nur den + // eigenen Bezirk sehen darf, filtert damit innerhalb dieser Auswahl. + ...(input.bezirkId?.length + ? [{ bezirkId: { in: input.bezirkId } }] + : []), + ...(searchWhere ? [searchWhere] : []), + ], + }; const [coursesRaw, total] = await Promise.all([ ctx.db.course.findMany({ diff --git a/src/server/api/routers/events.ts b/src/server/api/routers/events.ts index 8c04523c..945a93ce 100644 --- a/src/server/api/routers/events.ts +++ b/src/server/api/routers/events.ts @@ -242,6 +242,10 @@ export const eventsRouter = createTRPCRouter({ .default("eventDate"), sortOrder: z.enum(["asc", "desc"]).default("asc"), schedule: z.enum(["active", "all", "past"]).default("active"), + /** Set-Filter über Bezirke; leer heißt "alle". */ + bezirkId: z.array(z.string()).optional(), + /** Freitext über Titel und Ort, für die Suche der Tabellenansicht. */ + search: z.string().trim().max(200).optional(), }), ) .query(async ({ ctx, input }) => { @@ -316,6 +320,34 @@ export const eventsRouter = createTRPCRouter({ }; } + if (input.bezirkId?.length) { + // Zusätzlich zum Bezirks-Scope, nicht statt seiner: wer nur den eigenen + // Bezirk sehen darf, filtert damit innerhalb dieser Auswahl. + where = { + AND: [{ ...where }, { bezirkId: { in: input.bezirkId } }], + }; + } + + if (input.search) { + const search = input.search; + where = { + AND: [ + { ...where }, + { + OR: [ + { title: { contains: search, mode: "insensitive" } }, + { + location: { city: { contains: search, mode: "insensitive" } }, + }, + { + location: { name: { contains: search, mode: "insensitive" } }, + }, + ], + }, + ], + }; + } + const [events, total] = await Promise.all([ ctx.db.event.findMany({ where, diff --git a/src/server/api/routers/posts.ts b/src/server/api/routers/posts.ts index 24a20de7..dc021098 100644 --- a/src/server/api/routers/posts.ts +++ b/src/server/api/routers/posts.ts @@ -881,6 +881,10 @@ export const postsRouter = createTRPCRouter({ .enum(["publishedAt", "title", "createdAt", "status"]) .default("createdAt"), sortOrder: z.enum(["asc", "desc"]).default("desc"), + /** Set-Filter über Bezirke; leer heißt "alle". */ + bezirkId: z.array(z.string()).optional(), + /** Freitext über Titel und Anrisstext, für die Suche der Tabellenansicht. */ + search: z.string().trim().max(200).optional(), }), ) .query(async ({ ctx, input }) => { @@ -946,6 +950,29 @@ export const postsRouter = createTRPCRouter({ where = { AND: [{ ...where }, scopeFilter] }; } + if (input.bezirkId?.length) { + // Zusätzlich zum Bezirks-Scope, nicht statt seiner: wer nur den eigenen + // Bezirk sehen darf, filtert damit innerhalb dieser Auswahl. + where = { + AND: [{ ...where }, { bezirkId: { in: input.bezirkId } }], + }; + } + + if (input.search) { + const search = input.search; + where = { + AND: [ + { ...where }, + { + OR: [ + { title: { contains: search, mode: "insensitive" } }, + { excerpt: { contains: search, mode: "insensitive" } }, + ], + }, + ], + }; + } + const [posts, total] = await Promise.all([ ctx.db.post.findMany({ where, From 9c9a2730926623e5a5e59db9ee1bf048ebaf9432 Mon Sep 17 00:00:00 2001 From: Lars Lehmann Date: Thu, 10 Sep 2026 16:13:29 +0200 Subject: [PATCH 8/9] =?UTF-8?q?fix(termine):=20Kursart=20"Sonstiges"=20hie?= =?UTF-8?q?=C3=9F=20auf=20der=20Kurskarte=20"Other"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Karten haben den Rohwert aus der Datenbank durch `capitalizeFirstLetter` geschickt. Für die meisten Werte geht das gut, für `OTHER` nicht: die Karte schrieb "Other", während der Filter daneben dieselbe Kursart "Andere" nennt. Die Zuordnung von Klartext zu Datenbankwert stand ohnehin schon in der Filterleiste. Sie liegt jetzt in `termine-labels` und wird in beide Richtungen aus derselben Tabelle abgeleitet, damit Filter und Liste nicht wieder auseinanderlaufen. --- src/app/_components/events/course-card.tsx | 4 +-- src/app/_components/events/event-card.tsx | 4 +-- src/lib/termine-labels.ts | 41 ++++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 src/lib/termine-labels.ts diff --git a/src/app/_components/events/course-card.tsx b/src/app/_components/events/course-card.tsx index 774b2d9c..fe5c299c 100644 --- a/src/app/_components/events/course-card.tsx +++ b/src/app/_components/events/course-card.tsx @@ -6,7 +6,7 @@ import { } from "@/lib/format-date-range"; import { formatAvailableSlots } from "@/lib/format-available-slots"; import { api } from "@/trpc/react"; -import { capitalizeFirstLetter } from "@/lib/utils"; +import { courseTypeLabel } from "@/lib/termine-labels"; import { getDistrictColor } from "@/lib/district-color"; import CourseCardSkeleton from "./course-card-skeleton"; import { isExternalCourse } from "@/lib/course-external"; @@ -79,7 +79,7 @@ export default function CourseCard({ className="rounded-full px-3 py-1 text-xs font-semibold text-white" style={{ backgroundColor: districtColor }} > - {capitalizeFirstLetter(courseType)} + {courseTypeLabel(courseType)}
diff --git a/src/app/_components/events/event-card.tsx b/src/app/_components/events/event-card.tsx index 000ba904..e476b914 100644 --- a/src/app/_components/events/event-card.tsx +++ b/src/app/_components/events/event-card.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { getDistrictColor } from "@/lib/district-color"; -import { capitalizeFirstLetter } from "@/lib/utils"; +import { eventCategoryLabel } from "@/lib/termine-labels"; import { AlertTriangle, Users, @@ -63,7 +63,7 @@ export default function EventCard({ >
- {capitalizeFirstLetter(category)} + {eventCategoryLabel(category)} {openToParticipants && ( diff --git a/src/lib/termine-labels.ts b/src/lib/termine-labels.ts new file mode 100644 index 00000000..02315836 --- /dev/null +++ b/src/lib/termine-labels.ts @@ -0,0 +1,41 @@ +/** + * Beschriftungen der Termin-Kategorien für die öffentliche Terminseite. + * + * Die Filterleiste arbeitet mit den Klartexten und übersetzt sie in die + * Datenbankwerte, die Listen gehen den umgekehrten Weg. Beide Richtungen aus + * derselben Tabelle abzuleiten hält sie zusammen — sonst heißt `OTHER` im + * Filter "Andere" und in der Liste "Other". + */ +export const EVENT_CATEGORY_MAP: Record = { + Konzert: "KONZERT", + Gottesdienst: "GOTTESDIENST", + Probe: "PROBE", + Andere: "ANDERE", +}; + +export const COURSE_TYPE_MAP: Record = { + Lehrgang: "LEHRGANG", + Freizeit: "FREIZEIT", + Workshop: "WORKSHOP", + Komponistenportrait: "KOMPONISTENPORTRAIT", + Veranstaltung: "VERANSTALTUNG", + Andere: "OTHER", +}; + +function invert(map: Record): Record { + return Object.fromEntries( + Object.entries(map).map(([label, value]) => [value, label]), + ); +} + +const EVENT_CATEGORY_LABELS = invert(EVENT_CATEGORY_MAP); +const COURSE_TYPE_LABELS = invert(COURSE_TYPE_MAP); + +/** Fällt auf den Rohwert zurück, falls die Datenbank einen neuen Wert kennt. */ +export function eventCategoryLabel(category: string): string { + return EVENT_CATEGORY_LABELS[category] ?? category; +} + +export function courseTypeLabel(courseType: string): string { + return COURSE_TYPE_LABELS[courseType] ?? courseType; +} From 095ef8e1330ec5cbc4e96e27763b3140a6e295e9 Mon Sep 17 00:00:00 2001 From: Lars Lehmann Date: Thu, 10 Sep 2026 16:14:03 +0200 Subject: [PATCH 9/9] =?UTF-8?q?feat(termine):=20Monats=C3=BCberschriften?= =?UTF-8?q?=20abschaltbar,=20Abschnitte=20entschachtelt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jeder Monat saß in einer Box mit grauer Kopfzeile, und darin standen Karten — eine Verschachtelung, die es inhaltlich nicht gibt. Die Überschrift ist jetzt Typografie mit einer Haarlinie darunter und einem Chevron, der sich beim Zuklappen dreht. Aufklappbar bleibt sie. Die vergangenen Termine hatten zusätzlich eine eigene Monatsebene: ein Klapper im Klapper, zwei Ebenen für eine Liste, die man ohnehin von neu nach alt durchliest. Sie stehen jetzt flach unter ihrem Abschnitt. Neu ist ein Schalter in der Filterleiste, der die Monatsüberschriften ganz abschaltet. Bei wenigen Terminen je Monat bricht das Kartenraster sonst nach jeder einzelnen Karte um, und es steht mehr Überschrift als Inhalt auf der Seite; ohne Gruppierung fließen alle Termine durch dasselbe Raster. Die Wahl wird gespeichert. Der Speicher-Hook dafür lag schon im Ansichtsumschalter des Dashboards und ist jetzt geteilt, statt das Abonnenten-Muster ein zweites Mal abzuschreiben. --- .../dashboard/dashboard-list-view.tsx | 58 +-- src/app/_components/events/events-client.tsx | 409 ++++++++---------- src/lib/use-stored-preference.ts | 62 +++ 3 files changed, 257 insertions(+), 272 deletions(-) create mode 100644 src/lib/use-stored-preference.ts diff --git a/src/app/_components/dashboard/dashboard-list-view.tsx b/src/app/_components/dashboard/dashboard-list-view.tsx index 8b3bccfa..76763543 100644 --- a/src/app/_components/dashboard/dashboard-list-view.tsx +++ b/src/app/_components/dashboard/dashboard-list-view.tsx @@ -1,11 +1,15 @@ "use client"; -import { useCallback, useSyncExternalStore } from "react"; +import { useStoredPreference } from "@/lib/use-stored-preference"; import { LayoutGridIcon, TableIcon } from "lucide-react"; import { cn } from "@/lib/utils"; export type DashboardListView = "cards" | "table"; +function isDashboardListView(value: string): value is DashboardListView { + return value === "cards" || value === "table"; +} + const VIEWS: { value: DashboardListView; label: string; @@ -26,64 +30,20 @@ const VIEWS: { }, ]; -/** - * Abonnenten der gespeicherten Ansicht. `localStorage` meldet Änderungen nur an - * *andere* Tabs, nicht an den schreibenden — die Listen dieses Tabs brauchen - * deshalb einen eigenen Verteiler. - */ -const listeners = new Set<() => void>(); - -function subscribe(onChange: () => void) { - listeners.add(onChange); - window.addEventListener("storage", onChange); - return () => { - listeners.delete(onChange); - window.removeEventListener("storage", onChange); - }; -} - -function readView(storageKey: string): DashboardListView | null { - try { - const stored = window.localStorage.getItem(storageKey); - return stored === "cards" || stored === "table" ? stored : null; - } catch { - // Privater Modus oder blockierte Site-Daten: dann eben die Vorgabe. - return null; - } -} - /** * Kartenraster oder Tabelle — die Wahl bleibt pro Liste gespeichert, weil sie * zur Arbeitsweise gehört und nicht zur einzelnen Sitzung: wer die Termine * lieber als Tabelle pflegt, will sie beim nächsten Aufruf wieder so sehen. - * - * `useSyncExternalStore` statt eines Effekts: der Server kennt `localStorage` - * nicht und liefert immer die Vorgabe, und React weiß dadurch selbst, dass die - * erste Client-Ausgabe davon abweichen darf. */ export function useDashboardListView( storageKey: string, fallback: DashboardListView = "cards", ): [DashboardListView, (next: DashboardListView) => void] { - const view = useSyncExternalStore( - subscribe, - () => readView(storageKey) ?? fallback, - () => fallback, + return useStoredPreference( + storageKey, + fallback, + isDashboardListView, ); - - const update = useCallback( - (next: DashboardListView) => { - try { - window.localStorage.setItem(storageKey, next); - } catch { - // Nicht speicherbar — dann bleibt es bei der Vorgabe. - } - listeners.forEach((listener) => listener()); - }, - [storageKey], - ); - - return [view, update]; } /** Umschalter zwischen Kartenraster und Tabelle. */ diff --git a/src/app/_components/events/events-client.tsx b/src/app/_components/events/events-client.tsx index 38a36ae2..58ef91da 100644 --- a/src/app/_components/events/events-client.tsx +++ b/src/app/_components/events/events-client.tsx @@ -2,6 +2,7 @@ import { Select } from "@/app/_components/ui"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useStoredPreference } from "@/lib/use-stored-preference"; import { useRouter, useSearchParams } from "next/navigation"; import type { EventWithRelations, @@ -17,12 +18,13 @@ import PublicPage from "../general/public-page"; import { useBanner } from "../ui/banner-context"; import EventCard from "./event-card"; import CourseCard from "./course-card"; +import { COURSE_TYPE_MAP, EVENT_CATEGORY_MAP } from "@/lib/termine-labels"; import CalendarView from "./calendar/calendar-view"; import DesktopCalendarView from "./calendar/desktop-calendar-view"; import { CalendarIcon, + CalendarRangeIcon, ChevronDownIcon, - ChevronRightIcon, FunnelIcon, FunnelXIcon, } from "lucide-react"; @@ -30,23 +32,66 @@ import { ListIcon, Calendar } from "lucide-react"; import FeedConfigModal from "../feeds/feed-config-modal"; type ViewMode = "list" | "calendar"; + +const VIEW_MODES: ViewMode[] = ["list", "calendar"]; + +/** + * Monatsüberschriften an oder aus. Ohne sie fließt das Kartenraster + * durchgehend, statt nach jedem Monat umzubrechen — bei wenigen Terminen je + * Monat steht sonst mehr Überschrift als Inhalt auf der Seite. + */ +type MonthGrouping = "on" | "off"; + +function isMonthGrouping(value: string): value is MonthGrouping { + return value === "on" || value === "off"; +} + +function isViewMode(value: string | null): value is ViewMode { + return value !== null && (VIEW_MODES as string[]).includes(value); +} type FilterType = "all" | "events" | "courses"; -const EVENT_CATEGORY_MAP: Record = { - Konzert: "KONZERT", - Gottesdienst: "GOTTESDIENST", - Probe: "PROBE", - Andere: "ANDERE", -}; - -const COURSE_TYPE_MAP: Record = { - Lehrgang: "LEHRGANG", - Freizeit: "FREIZEIT", - Workshop: "WORKSHOP", - Komponistenportrait: "KOMPONISTENPORTRAIT", - Veranstaltung: "VERANSTALTUNG", - Andere: "OTHER", -}; +/** + * Monatsüberschrift zum Auf- und Zuklappen. + * + * Bewusst nur Typografie und eine Haarlinie statt einer Kachel mit grauer + * Kopfzeile: die Termine darunter sind in beiden Ansichten selbst schon Karten + * oder Zeilen, und eine Box um Boxen legt eine Verschachtelung nahe, die es + * inhaltlich nicht gibt. + */ +function MonthHeading({ + label, + count, + expanded, + onToggle, +}: { + label: string; + count: number; + expanded: boolean; + onToggle: () => void; +}) { + return ( + + ); +} interface EventsClientProps { initialEvents: EventWithRelations[]; @@ -87,10 +132,7 @@ export default function EventsClient({ typeof profile.preferences === "string" ? JSON.parse(profile.preferences) : profile.preferences; - if ( - prefs.termineDefaultView === "calendar" || - prefs.termineDefaultView === "list" - ) { + if (isViewMode(prefs.termineDefaultView)) { return prefs.termineDefaultView; } } catch {} @@ -117,7 +159,7 @@ export default function EventsClient({ const [viewWhenNoUrl, setViewWhenNoUrl] = useState("list"); const effectiveViewMode = useMemo((): ViewMode => { - if (viewParam === "list" || viewParam === "calendar") { + if (isViewMode(viewParam)) { return viewParam; } if (userHasChangedView) { @@ -152,6 +194,12 @@ export default function EventsClient({ const [selectedCategory, setSelectedCategory] = useState( params.get("category") || "all", ); + const [monthGrouping, setMonthGrouping] = useStoredPreference( + "termineMonthGrouping", + "on", + isMonthGrouping, + ); + const groupByMonth = monthGrouping === "on"; const [filtersOpen, setFiltersOpen] = useState(false); const [icalModalOpen, setIcalModalOpen] = useState(false); @@ -277,30 +325,6 @@ export default function EventsClient({ }); }, [allItems, now, applyFilters]); - const pastGroupedByMonth = useMemo(() => { - return pastItems.reduce( - (acc, item) => { - const date = new Date( - item.type === "event" ? item.eventDate : item.startDate, - ); - const monthKey = `${date.getFullYear()}-${String( - date.getMonth() + 1, - ).padStart(2, "0")}`; - const monthLabel = date.toLocaleDateString("de-DE", { - year: "numeric", - month: "long", - }); - - if (!acc[monthKey]) { - acc[monthKey] = { label: monthLabel, items: [] }; - } - acc[monthKey].items.push(item); - return acc; - }, - {} as Record, - ); - }, [pastItems]); - const [collapsedMonths, setCollapsedMonths] = useState>( new Set(), ); @@ -321,25 +345,42 @@ export default function EventsClient({ const [pastEventsExpanded, setPastEventsExpanded] = useState(false); - const [expandedPastMonths, setExpandedPastMonths] = useState>( - new Set(), - ); - - const togglePastMonth = (monthKey: string) => { - setExpandedPastMonths((prev) => { - const newSet = new Set(prev); - if (newSet.has(monthKey)) { - newSet.delete(monthKey); - } else { - newSet.add(monthKey); - } - return newSet; - }); + /** Ein Kartenraster — mit oder ohne Monatsüberschrift darüber. */ + const renderItemGroup = (items: CalendarItem[], keyPrefix: string) => { + return ( +
+ {items.map((item) => + item.type === "event" ? ( + + ) : ( + + ), + )} +
+ ); }; - const isPastMonthExpanded = (monthKey: string) => - expandedPastMonths.has(monthKey); - const districtSelectOptions = [ "all", "Bezirksübergreifend", @@ -385,7 +426,8 @@ export default function EventsClient({ ? "bg-primary text-white" : "text-dark dark:text-dark-text dark:bg-dark-background-secondary dark:hover:bg-dark-background bg-gray-100 hover:bg-gray-200" }`} - aria-label="Listenansicht" + aria-label="Kartenansicht" + title="Kartenansicht" > @@ -397,6 +439,7 @@ export default function EventsClient({ : "text-dark dark:text-dark-text dark:bg-dark-background-secondary dark:hover:bg-dark-background bg-gray-100 hover:bg-gray-200" }`} aria-label="Kalenderansicht" + title="Kalenderansicht" > @@ -559,6 +602,43 @@ export default function EventsClient({
+ {/* Darstellung */} +
+ + +
+ {/* Reset Button */} {(filterType !== "all" || selectedDistrict !== "all" || @@ -583,71 +663,32 @@ export default function EventsClient({
{effectiveViewMode === "list" ? ( - /* List View - Grouped by month */ -
- {/* Upcoming Events */} - {Object.entries(groupedByMonth).map( - ([monthKey, { label, items }]) => ( -
- - {isMonthExpanded(monthKey) && ( -
-
- {items.map((item) => - item.type === "event" ? ( - - ) : ( - - ), - )} -
+ /* Kartenraster, wahlweise nach Monaten gruppiert */ +
+ {/* Upcoming Events — ohne Monatsgruppierung fließen alle + Termine durch dasselbe Raster, statt nach jedem Monat + umzubrechen. */} + {groupByMonth + ? Object.entries(groupedByMonth).map( + ([monthKey, { label, items }]) => ( +
+ toggleMonth(monthKey)} + /> + {isMonthExpanded(monthKey) && ( +
+ {renderItemGroup(items, monthKey)} +
+ )}
- )} -
- ), - )} + ), + ) + : sortedItems.length > 0 + ? renderItemGroup(sortedItems, "upcoming") + : null} {sortedItems.length === 0 && (
@@ -657,103 +698,25 @@ export default function EventsClient({
)} - {/* Past Events Section */} + {/* Past Events Section — ohne Monatsgruppierung: die + Vergangenheit ist ein Nachschlagewerk, keine Planung. Wer + hier aufklappt, sucht einen bestimmten Termin und liest die + Liste von neu nach alt durch. */} {pastItems.length > 0 && ( -
-
- - {pastEventsExpanded && ( -
- {Object.entries(pastGroupedByMonth) - .sort(([a], [b]) => b.localeCompare(a)) // Sort months descending (most recent first) - .map(([monthKey, { label, items }]) => ( -
- - {isPastMonthExpanded(monthKey) && ( -
-
- {items.map((item) => - item.type === "event" ? ( - - ) : ( - - ), - )} -
-
- )} -
- ))} -
- )} -
+
+ + setPastEventsExpanded(!pastEventsExpanded) + } + /> + {pastEventsExpanded && ( +
+ {renderItemGroup(pastItems, "past")} +
+ )}
)}
diff --git a/src/lib/use-stored-preference.ts b/src/lib/use-stored-preference.ts new file mode 100644 index 00000000..f7fa7a0b --- /dev/null +++ b/src/lib/use-stored-preference.ts @@ -0,0 +1,62 @@ +"use client"; + +import { useCallback, useSyncExternalStore } from "react"; + +/** + * Abonnenten gespeicherter Einstellungen. `localStorage` meldet Änderungen nur + * an *andere* Tabs, nicht an den schreibenden — die Komponenten dieses Tabs + * brauchen deshalb einen eigenen Verteiler. + */ +const listeners = new Set<() => void>(); + +function subscribe(onChange: () => void) { + listeners.add(onChange); + window.addEventListener("storage", onChange); + return () => { + listeners.delete(onChange); + window.removeEventListener("storage", onChange); + }; +} + +/** + * Eine Anzeigeeinstellung, die den Besuch überdauert. + * + * `useSyncExternalStore` statt eines Effekts: der Server kennt `localStorage` + * nicht und liefert immer die Vorgabe, und React weiß dadurch selbst, dass die + * erste Client-Ausgabe davon abweichen darf. + * + * `isValid` entscheidet, ob ein gespeicherter Wert noch zum heutigen Code + * passt — sonst gilt die Vorgabe. Das fängt alte Werte ab, die es einmal gab + * und heute nicht mehr gibt. + */ +export function useStoredPreference( + storageKey: string, + fallback: T, + isValid: (value: string) => value is T, +): [T, (next: T) => void] { + const read = useCallback((): T => { + try { + const stored = window.localStorage.getItem(storageKey); + return stored !== null && isValid(stored) ? stored : fallback; + } catch { + // Privater Modus oder blockierte Site-Daten: dann eben die Vorgabe. + return fallback; + } + }, [storageKey, fallback, isValid]); + + const value = useSyncExternalStore(subscribe, read, () => fallback); + + const update = useCallback( + (next: T) => { + try { + window.localStorage.setItem(storageKey, next); + } catch { + // Nicht speicherbar — dann bleibt es bei der Vorgabe. + } + listeners.forEach((listener) => listener()); + }, + [storageKey], + ); + + return [value, update]; +}