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/dashboard/content-status.tsx b/src/app/_components/dashboard/content-status.tsx new file mode 100644 index 00000000..e233e3b8 --- /dev/null +++ b/src/app/_components/dashboard/content-status.tsx @@ -0,0 +1,45 @@ +import type { ContentStatus } from "~/generated/prisma/enums"; + +/** + * Ein Freigabestatus, eine Beschriftung, eine Farbe — Karten, Tabellen und + * Filterlisten der Übersichten greifen auf dieselbe Tabelle zu, damit derselbe + * Status nicht je nach Ansicht anders heißt. + */ +export const CONTENT_STATUS_LABELS: Record = { + DRAFT: "Entwurf", + PENDING: "Zur Prüfung", + APPROVED: "Veröffentlicht", + REJECTED: "Abgelehnt", + ARCHIVED: "Archiviert", +}; + +export const CONTENT_STATUS_BADGE_CLASSES: Record = { + DRAFT: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300", + PENDING: + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300", + APPROVED: + "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300", + REJECTED: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300", + ARCHIVED: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400", +}; + +/** Set-Filter-Optionen für eine Statusspalte. */ +export const CONTENT_STATUS_OPTIONS = ( + Object.keys(CONTENT_STATUS_LABELS) as ContentStatus[] +).map((value) => ({ value, label: CONTENT_STATUS_LABELS[value] })); + +export function ContentStatusBadge({ + status, + className, +}: { + status: ContentStatus; + className?: string; +}) { + return ( + + {CONTENT_STATUS_LABELS[status]} + + ); +} 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/dashboard-course-card.tsx b/src/app/_components/dashboard/dashboard-course-card.tsx index 8979340f..8e506a8b 100644 --- a/src/app/_components/dashboard/dashboard-course-card.tsx +++ b/src/app/_components/dashboard/dashboard-course-card.tsx @@ -2,6 +2,10 @@ import Link from "next/link"; import { getDistrictColor } from "@/lib/district-color"; import { coursePath } from "@/lib/slug"; import type { ContentStatus, CourseType } from "~/generated/prisma/enums"; +import { + CONTENT_STATUS_BADGE_CLASSES, + CONTENT_STATUS_LABELS, +} from "./content-status"; import { Calendar, MapPin, @@ -35,37 +39,6 @@ interface DashboardCourseCardProps { createdAt?: Date; } -const statusConfig: Record< - ContentStatus, - { label: string; bgColor: string; textColor: string } -> = { - DRAFT: { - label: "Entwurf", - bgColor: "bg-gray-100 dark:bg-gray-800", - textColor: "text-gray-700 dark:text-gray-300", - }, - PENDING: { - label: "Zur Prüfung", - bgColor: "bg-yellow-100 dark:bg-yellow-900/30", - textColor: "text-yellow-800 dark:text-yellow-300", - }, - APPROVED: { - label: "Veröffentlicht", - bgColor: "bg-green-100 dark:bg-green-900/30", - textColor: "text-green-800 dark:text-green-300", - }, - REJECTED: { - label: "Abgelehnt", - bgColor: "bg-red-100 dark:bg-red-900/30", - textColor: "text-red-800 dark:text-red-300", - }, - ARCHIVED: { - label: "Archiviert", - bgColor: "bg-gray-100 dark:bg-gray-800", - textColor: "text-gray-600 dark:text-gray-400", - }, -}; - const courseTypeLabels: Record = { LEHRGANG: "Lehrgang", FREIZEIT: "Freizeit", @@ -94,7 +67,7 @@ export default function DashboardCourseCard({ createdAt, }: DashboardCourseCardProps) { const districtColor = getDistrictColor(district); - const statusInfo = statusConfig[status]; + const statusClasses = CONTENT_STATUS_BADGE_CLASSES[status]; const isFull = maxParticipants ? confirmedCount >= maxParticipants : false; const isDeadlinePassed = registrationDeadline ? new Date(registrationDeadline) < new Date() @@ -137,9 +110,9 @@ export default function DashboardCourseCard({
- {statusInfo.label} + {CONTENT_STATUS_LABELS[status]} {isRegistrationNotOpenYet && registrationOpen ? ( diff --git a/src/app/_components/dashboard/dashboard-courses-list.tsx b/src/app/_components/dashboard/dashboard-courses-list.tsx index a4da16f5..3e839402 100644 --- a/src/app/_components/dashboard/dashboard-courses-list.tsx +++ b/src/app/_components/dashboard/dashboard-courses-list.tsx @@ -1,16 +1,35 @@ "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 { getDistrictColor } from "@/lib/district-color"; +import { coursePath } from "@/lib/slug"; import DashboardCourseCard from "./dashboard-course-card"; -import type { ContentStatus } from "~/generated/prisma/enums"; +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, CourseType } from "~/generated/prisma/enums"; import { useToast } from "@/app/_components/ui/toast"; import { ArrowDownIcon, ArrowLeftIcon, + ExternalLinkIcon, ArrowRightIcon, ArrowUpIcon, CheckIcon, @@ -32,6 +51,26 @@ import { cn } from "@/lib/utils"; type DashboardCoursesListProps = Record; +type DashboardCourse = + RouterOutputs["courses"]["getDashboardCourses"]["courses"][number]; + +/** Die Standardordnung der Liste — auch das Ziel des dritten Sortierklicks. */ +const DEFAULT_SORTING = { id: "startDate", desc: false } as const; + +/** Die Spalten, nach denen der Server sortieren kann. */ +type TableSortColumn = "title" | "startDate" | "status" | "createdAt"; + +const courseTypeLabels: Record = { + LEHRGANG: "Lehrgang", + FREIZEIT: "Freizeit", + WORKSHOP: "Workshop", + KOMPONISTENPORTRAIT: "Komponistenportrait", + VERANSTALTUNG: "Veranstaltung", + OTHER: "Sonstiges", +}; + +const column = createDataTableColumnHelper(); + type DashboardCoursesScheduleFilter = "active" | "all" | "past"; const scheduleFilters: { @@ -70,21 +109,38 @@ export default function DashboardCoursesList({}: DashboardCoursesListProps) { ); const [scheduleFilter, setScheduleFilter] = useState("active"); - const [sortBy, setSortBy] = useState< - "startDate" | "title" | "createdAt" | "status" - >("startDate"); - 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-courses-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.courses.getDashboardCourses.useQuery({ page, limit, @@ -92,6 +148,8 @@ export default function DashboardCoursesList({}: DashboardCoursesListProps) { schedule: scheduleFilter, sortBy, sortOrder, + bezirkId: bezirkFilter.length ? bezirkFilter : undefined, + search: view === "table" && search ? search : undefined, }); useEffect(() => { @@ -167,17 +225,58 @@ export default function DashboardCoursesList({}: DashboardCoursesListProps) { const adjustedFilterCount = useMemo(() => { return ( (statusFilter !== "all" ? 1 : 0) + + (bezirkFilter.length > 0 ? 1 : 0) + (scheduleFilter !== "active" ? 1 : 0) + (sortBy !== "startDate" || 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 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 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)) { @@ -187,7 +286,191 @@ export default function DashboardCoursesList({}: DashboardCoursesListProps) { } 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((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-event-card.tsx b/src/app/_components/dashboard/dashboard-event-card.tsx index 7efa6328..7fb308a7 100644 --- a/src/app/_components/dashboard/dashboard-event-card.tsx +++ b/src/app/_components/dashboard/dashboard-event-card.tsx @@ -2,6 +2,10 @@ import Link from "next/link"; import { getDistrictColor } from "@/lib/district-color"; import { capitalizeFirstLetter } from "@/lib/utils"; import type { ContentStatus } from "~/generated/prisma/enums"; +import { + CONTENT_STATUS_BADGE_CLASSES, + CONTENT_STATUS_LABELS, +} from "./content-status"; import { Calendar, Eye, @@ -30,37 +34,6 @@ interface DashboardEventCardProps { createdAt?: Date; } -const statusConfig: Record< - ContentStatus, - { label: string; bgColor: string; textColor: string } -> = { - DRAFT: { - label: "Entwurf", - bgColor: "bg-gray-100 dark:bg-gray-800", - textColor: "text-gray-700 dark:text-gray-300", - }, - PENDING: { - label: "Zur Prüfung", - bgColor: "bg-yellow-100 dark:bg-yellow-900/30", - textColor: "text-yellow-800 dark:text-yellow-300", - }, - APPROVED: { - label: "Veröffentlicht", - bgColor: "bg-green-100 dark:bg-green-900/30", - textColor: "text-green-800 dark:text-green-300", - }, - REJECTED: { - label: "Abgelehnt", - bgColor: "bg-red-100 dark:bg-red-900/30", - textColor: "text-red-800 dark:text-red-300", - }, - ARCHIVED: { - label: "Archiviert", - bgColor: "bg-gray-100 dark:bg-gray-800", - textColor: "text-gray-600 dark:text-gray-400", - }, -}; - export default function DashboardEventCard({ id, slug, @@ -75,7 +48,7 @@ export default function DashboardEventCard({ createdAt, }: DashboardEventCardProps) { const districtColor = getDistrictColor(district); - const statusInfo = statusConfig[status]; + const statusClasses = CONTENT_STATUS_BADGE_CLASSES[status]; const creatorLine = createdBy && @@ -108,9 +81,9 @@ export default function DashboardEventCard({
- {statusInfo.label} + {CONTENT_STATUS_LABELS[status]} {cancelled ? ( 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..76763543 --- /dev/null +++ b/src/app/_components/dashboard/dashboard-list-view.tsx @@ -0,0 +1,92 @@ +"use client"; + +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; + title: string; + icon: typeof TableIcon; +}[] = [ + { + value: "cards", + label: "Karten", + title: "Kartenansicht", + icon: LayoutGridIcon, + }, + { + value: "table", + label: "Tabelle", + title: "Tabellenansicht", + icon: TableIcon, + }, +]; + +/** + * 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. + */ +export function useDashboardListView( + storageKey: string, + fallback: DashboardListView = "cards", +): [DashboardListView, (next: DashboardListView) => void] { + return useStoredPreference( + storageKey, + fallback, + isDashboardListView, + ); +} + +/** 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-post-card.tsx b/src/app/_components/dashboard/dashboard-post-card.tsx index 894690c7..bf51d0d1 100644 --- a/src/app/_components/dashboard/dashboard-post-card.tsx +++ b/src/app/_components/dashboard/dashboard-post-card.tsx @@ -1,6 +1,10 @@ import Link from "next/link"; import { getDistrictColor } from "@/lib/district-color"; import type { ContentStatus, PostCategory } from "~/generated/prisma/client"; +import { + CONTENT_STATUS_BADGE_CLASSES, + CONTENT_STATUS_LABELS, +} from "./content-status"; import { CalendarIcon, CheckCircleIcon, @@ -33,37 +37,6 @@ interface DashboardPostCardProps { reviewDate?: Date | null; } -const statusConfig: Record< - ContentStatus, - { label: string; bgColor: string; textColor: string } -> = { - DRAFT: { - label: "Entwurf", - bgColor: "bg-gray-100 dark:bg-gray-800", - textColor: "text-gray-700 dark:text-gray-300", - }, - PENDING: { - label: "Zur Prüfung", - bgColor: "bg-yellow-100 dark:bg-yellow-900/30", - textColor: "text-yellow-800 dark:text-yellow-300", - }, - APPROVED: { - label: "Veröffentlicht", - bgColor: "bg-green-100 dark:bg-green-900/30", - textColor: "text-green-800 dark:text-green-300", - }, - REJECTED: { - label: "Abgelehnt", - bgColor: "bg-red-100 dark:bg-red-900/30", - textColor: "text-red-800 dark:text-red-300", - }, - ARCHIVED: { - label: "Archiviert", - bgColor: "bg-gray-100 dark:bg-gray-800", - textColor: "text-gray-600 dark:text-gray-400", - }, -}; - const categoryConfig: Record< PostCategory, { label: string; bgColor: string; textColor: string } @@ -110,7 +83,7 @@ export default function DashboardPostCard({ reviewDate, }: DashboardPostCardProps) { const districtColor = getDistrictColor(district); - const statusInfo = statusConfig[status]; + const statusClasses = CONTENT_STATUS_BADGE_CLASSES[status]; const categoryInfo = categoryConfig[category]; const metaIconClass = "mt-0.5 h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"; @@ -122,9 +95,9 @@ export default function DashboardPostCard({
{/* Status Badge */} - {statusInfo.label} + {CONTENT_STATUS_LABELS[status]} {/* Pinned Badge */} 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/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/_components/dashboard/index.ts b/src/app/_components/dashboard/index.ts index bf1e5f07..deb17678 100644 --- a/src/app/_components/dashboard/index.ts +++ b/src/app/_components/dashboard/index.ts @@ -45,3 +45,15 @@ export { type CreatedLocation, } 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/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/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/app/_components/ui/README.md b/src/app/_components/ui/README.md index 2296409d..eb50a4b1 100644 --- a/src/app/_components/ui/README.md +++ b/src/app/_components/ui/README.md @@ -183,10 +183,139 @@ 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. + +**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 -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 +326,16 @@ function MyForm() {
- +
- +
@@ -222,7 +355,9 @@ function MyForm() {
- +
@@ -234,6 +369,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 +378,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"; 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/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" > (); + +/** 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/courses/[id]/mail/page.tsx b/src/app/dashboard/courses/[id]/mail/page.tsx index 65599987..d4040a9e 100644 --- a/src/app/dashboard/courses/[id]/mail/page.tsx +++ b/src/app/dashboard/courses/[id]/mail/page.tsx @@ -8,7 +8,10 @@ import { api } from "@/trpc/react"; import { useToast } from "@/app/_components/ui/toast"; import { useAutosave } from "@/lib/useAutosave"; import { useBeforeUnload } from "@/lib/useBeforeUnload"; -import { DraftRestorePrompt } from "@/app/_components/dashboard"; +import { + CourseInvoicesButton, + DraftRestorePrompt, +} from "@/app/_components/dashboard"; import RichTextEditor from "@/app/_components/editor/rich-text-editor-lazy"; import { ScrollableModal, @@ -515,13 +518,16 @@ function CourseMailPageContent() { {course.title}

- - - 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 ? (
{ - 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/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/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/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/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 + + + } + /> ); } 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; +} 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]; +} 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/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/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/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), })); }, ), 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, 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 }), ]);