- {startIdx + 1}–{Math.min(startIdx + PAGE_SIZE, totalItems)}{' '}
- von {totalItems}
+ Seite {safePage} von {totalPages}
);
}
diff --git a/packages/features/member-management/src/components/members-data-table.tsx b/packages/features/member-management/src/components/members-data-table.tsx
index 0f1014b40..3f062920e 100644
--- a/packages/features/member-management/src/components/members-data-table.tsx
+++ b/packages/features/member-management/src/components/members-data-table.tsx
@@ -4,16 +4,20 @@ import { useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
-import { useAction } from 'next-safe-action/hooks';
+import { Download } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { formatDate } from '@kit/shared/dates';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
-import { toast } from '@kit/ui/sonner';
+import { useFileDownloadAction } from '@kit/ui/use-file-download-action';
import { STATUS_LABELS, getMemberStatusColor } from '../lib/member-utils';
+import {
+ exportMembers,
+ exportMembersExcel,
+} from '../server/actions/member-actions';
interface MembersDataTableProps {
data: Array
>;
@@ -21,6 +25,7 @@ interface MembersDataTableProps {
page: number;
pageSize: number;
account: string;
+ accountId: string;
duesCategories: Array<{ id: string; name: string }>;
}
@@ -38,6 +43,7 @@ export function MembersDataTable({
page,
pageSize,
account,
+ accountId,
duesCategories,
}: MembersDataTableProps) {
const router = useRouter();
@@ -102,6 +108,20 @@ export function MembersDataTable({
[router, account],
);
+ const { execute: execCsvExport, isPending: isCsvExporting } =
+ useFileDownloadAction(exportMembers, {
+ successMessage: 'CSV-Export heruntergeladen',
+ errorMessage: 'CSV-Export fehlgeschlagen',
+ });
+
+ const { execute: execExcelExport, isPending: isExcelExporting } =
+ useFileDownloadAction(exportMembersExcel, {
+ successMessage: 'Excel-Export heruntergeladen',
+ errorMessage: 'Excel-Export fehlgeschlagen',
+ });
+
+ const isExporting = isCsvExporting || isExcelExporting;
+
return (
{/* Toolbar */}
@@ -137,6 +157,34 @@ export function MembersDataTable({
))}
+
+
{errorMsg && (
{errorMsg}
diff --git a/packages/features/verbandsverwaltung/src/components/create-club-form.tsx b/packages/features/verbandsverwaltung/src/components/create-club-form.tsx
index b2c0246c4..335988ab7 100644
--- a/packages/features/verbandsverwaltung/src/components/create-club-form.tsx
+++ b/packages/features/verbandsverwaltung/src/components/create-club-form.tsx
@@ -3,7 +3,6 @@
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
-import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button';
@@ -17,7 +16,7 @@ import {
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
-import { toast } from '@kit/ui/sonner';
+import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import { CreateMemberClubSchema } from '../schema/verband.schema';
import { createClub } from '../server/actions/verband-actions';
@@ -62,15 +61,11 @@ export function CreateClubForm({
},
});
- const { execute, isPending } = useAction(createClub, {
- onSuccess: ({ data }) => {
- if (data?.success) {
- toast.success(isEdit ? 'Verein aktualisiert' : 'Verein erstellt');
- router.push(`/home/${account}/verband/clubs`);
- }
- },
- onError: ({ error }) => {
- toast.error(error.serverError ?? 'Fehler beim Speichern');
+ const { execute, isPending } = useActionWithToast(createClub, {
+ successMessage: isEdit ? 'Verein aktualisiert' : 'Verein erstellt',
+ errorMessage: 'Fehler beim Speichern',
+ onSuccess: () => {
+ router.push(`/home/${account}/verband/clubs`);
},
});
diff --git a/packages/features/verbandsverwaltung/src/components/cross-org-member-search.tsx b/packages/features/verbandsverwaltung/src/components/cross-org-member-search.tsx
index ea3727218..8364f2ff5 100644
--- a/packages/features/verbandsverwaltung/src/components/cross-org-member-search.tsx
+++ b/packages/features/verbandsverwaltung/src/components/cross-org-member-search.tsx
@@ -22,8 +22,8 @@ import {
DialogTitle,
} from '@kit/ui/dialog';
import { Input } from '@kit/ui/input';
-import { toast } from '@kit/ui/sonner';
import { Textarea } from '@kit/ui/textarea';
+import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import {
getTransferPreview,
@@ -90,7 +90,7 @@ export function CrossOrgMemberSearch({
const { execute: executePreview } = useAction(getTransferPreview, {
onSuccess: ({ data }) => {
- if (data) setPreview(data);
+ if (data?.data) setPreview(data.data);
setPreviewLoading(false);
},
onError: () => {
@@ -98,22 +98,18 @@ export function CrossOrgMemberSearch({
},
});
- const { execute: executeTransfer, isPending: isTransferring } = useAction(
- transferMember,
- {
+ const { execute: executeTransfer, isPending: isTransferring } =
+ useActionWithToast(transferMember, {
+ successMessage: 'Mitglied erfolgreich transferiert',
+ errorMessage: 'Fehler beim Transfer',
onSuccess: () => {
- toast.success('Mitglied erfolgreich transferiert');
setTransferTarget(null);
setTargetAccountId('');
setTransferReason('');
setKeepSepa(true);
setPreview(null);
},
- onError: ({ error }) => {
- toast.error(error.serverError ?? 'Fehler beim Transfer');
- },
- },
- );
+ });
const buildUrl = useCallback(
(params: Record) => {
diff --git a/packages/features/verbandsverwaltung/src/server/actions/hierarchy-actions.ts b/packages/features/verbandsverwaltung/src/server/actions/hierarchy-actions.ts
index 669a79591..bb0d05b8a 100644
--- a/packages/features/verbandsverwaltung/src/server/actions/hierarchy-actions.ts
+++ b/packages/features/verbandsverwaltung/src/server/actions/hierarchy-actions.ts
@@ -135,7 +135,8 @@ export const getTransferPreview = authActionClient
const client = getSupabaseServerClient();
const api = createVerbandApi(client);
- return api.getTransferPreview(input.memberId);
+ const data = await api.getTransferPreview(input.memberId);
+ return { success: true, data };
});
export const transferMember = authActionClient
@@ -169,7 +170,7 @@ export const transferMember = authActionClient
);
revalidatePath(REVALIDATE_PATH, 'page');
- return { success: true, transferId };
+ return { success: true, data: { transferId } };
} catch (err) {
const message =
err instanceof Error ? err.message : 'Fehler beim Transfer';
@@ -202,5 +203,5 @@ export const cloneTemplate = authActionClient
);
revalidatePath(REVALIDATE_PATH, 'page');
- return { success: true, newTemplateId };
+ return { success: true, data: { newTemplateId } };
});
diff --git a/packages/next/package.json b/packages/next/package.json
index 95ead2b5b..563a57e42 100644
--- a/packages/next/package.json
+++ b/packages/next/package.json
@@ -12,7 +12,8 @@
"exports": {
"./actions": "./src/actions/index.ts",
"./safe-action": "./src/actions/safe-action-client.ts",
- "./routes": "./src/routes/index.ts"
+ "./routes": "./src/routes/index.ts",
+ "./route-helpers": "./src/routes/api-helpers.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
diff --git a/packages/next/src/routes/api-helpers.ts b/packages/next/src/routes/api-helpers.ts
new file mode 100644
index 000000000..3873da6bd
--- /dev/null
+++ b/packages/next/src/routes/api-helpers.ts
@@ -0,0 +1,28 @@
+import 'server-only';
+import { NextResponse } from 'next/server';
+
+import * as z from 'zod';
+
+/**
+ * Shared Zod schemas for public API route validation.
+ */
+export const emailSchema = z.string().email('Ungültige E-Mail-Adresse');
+
+export const requiredString = (fieldName: string) =>
+ z.string().min(1, `${fieldName} ist erforderlich`);
+
+/**
+ * Create a success JSON response.
+ * Shape: { success: true, data: T }
+ */
+export function apiSuccess(data: T, status = 200) {
+ return NextResponse.json({ success: true, data }, { status });
+}
+
+/**
+ * Create an error JSON response.
+ * Shape: { success: false, error: string }
+ */
+export function apiError(error: string, status = 400) {
+ return NextResponse.json({ success: false, error }, { status });
+}
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 2d0d6a130..854b51699 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -16,7 +16,8 @@
"./registry": "./src/registry/index.ts",
"./env": "./src/env/index.ts",
"./dates": "./src/dates/index.ts",
- "./formatters": "./src/dates/index.ts"
+ "./formatters": "./src/dates/index.ts",
+ "./api-response": "./src/api-response.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
diff --git a/packages/shared/src/api-response.ts b/packages/shared/src/api-response.ts
new file mode 100644
index 000000000..fe53d5783
--- /dev/null
+++ b/packages/shared/src/api-response.ts
@@ -0,0 +1,46 @@
+/**
+ * Canonical API response types for all server actions and route handlers.
+ * Every action/endpoint should return one of these shapes.
+ */
+
+/** Successful action result */
+export type ActionSuccess = T extends void
+ ? { success: true }
+ : { success: true; data: T };
+
+/** Failed action result */
+export interface ActionError {
+ success: false;
+ error: string;
+ validationErrors?: Array<{ field: string; message: string }>;
+}
+
+/** Union of success and error */
+export type ActionResult = ActionSuccess | ActionError;
+
+/** Standardized file download payload (used inside ActionSuccess.data) */
+export interface FileDownload {
+ content: string;
+ filename: string;
+ mimeType: string;
+}
+
+/** Helper to create a success response */
+export function actionSuccess(): { success: true };
+export function actionSuccess(data: T): { success: true; data: T };
+export function actionSuccess(data?: T) {
+ if (data === undefined) {
+ return { success: true };
+ }
+ return { success: true, data };
+}
+
+/** Helper to create an error response */
+export function actionError(
+ error: string,
+ validationErrors?: Array<{ field: string; message: string }>,
+): ActionError {
+ return validationErrors
+ ? { success: false, error, validationErrors }
+ : { success: false, error };
+}
diff --git a/packages/ui/package.json b/packages/ui/package.json
index ddb5c22ab..08768776b 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -98,7 +98,10 @@
"./sidebar-navigation": "./src/makerkit/sidebar-navigation.tsx",
"./file-uploader": "./src/makerkit/file-uploader.tsx",
"./use-supabase-upload": "./src/hooks/use-supabase-upload.ts",
- "./csp-provider": "./src/base-ui/csp-provider.tsx"
+ "./csp-provider": "./src/base-ui/csp-provider.tsx",
+ "./list-toolbar": "./src/makerkit/list-toolbar.tsx",
+ "./use-action-with-toast": "./src/hooks/use-action-with-toast.ts",
+ "./use-file-download-action": "./src/hooks/use-file-download-action.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
diff --git a/packages/ui/src/hooks/use-action-with-toast.ts b/packages/ui/src/hooks/use-action-with-toast.ts
new file mode 100644
index 000000000..6e317c3f6
--- /dev/null
+++ b/packages/ui/src/hooks/use-action-with-toast.ts
@@ -0,0 +1,67 @@
+'use client';
+
+import { useAction } from 'next-safe-action/hooks';
+import type { HookSafeActionFn } from 'next-safe-action/hooks';
+
+import { toast } from '../shadcn/sonner';
+
+const DEFAULT_ERROR = 'Ein Fehler ist aufgetreten';
+
+interface UseActionWithToastOptions {
+ successMessage: string;
+ errorMessage?: string;
+ onSuccess?: (data: TData) => void;
+ onError?: (error: string, data?: TData) => void;
+ showSuccessToast?: boolean;
+}
+
+/**
+ * Wraps next-safe-action's `useAction` with unified toast notifications.
+ *
+ * Handles three cases:
+ * 1. Action succeeds with `{ success: true }` → toast.success
+ * 2. Action returns `{ success: false, error }` → toast.error
+ * 3. Action throws (serverError) → toast.error
+ */
+export function useActionWithToast<
+ TData extends { success: boolean; error?: string; [key: string]: unknown },
+>(
+ action: HookSafeActionFn,
+ options: UseActionWithToastOptions,
+) {
+ const {
+ successMessage,
+ errorMessage,
+ onSuccess,
+ onError,
+ showSuccessToast = true,
+ } = options;
+
+ return useAction(action, {
+ onSuccess: ({ data }) => {
+ if (!data) return;
+
+ // Application-level error: action returned { success: false }
+ if (!data.success) {
+ const msg = data.error ?? errorMessage ?? DEFAULT_ERROR;
+ toast.error(msg);
+ onError?.(msg, data);
+ return;
+ }
+
+ if (showSuccessToast) {
+ toast.success(successMessage);
+ }
+
+ onSuccess?.(data);
+ },
+ onError: ({ error }) => {
+ const msg =
+ (error as { serverError?: string }).serverError ??
+ errorMessage ??
+ DEFAULT_ERROR;
+ toast.error(msg);
+ onError?.(msg);
+ },
+ });
+}
diff --git a/packages/ui/src/hooks/use-file-download-action.ts b/packages/ui/src/hooks/use-file-download-action.ts
new file mode 100644
index 000000000..3fdb9e733
--- /dev/null
+++ b/packages/ui/src/hooks/use-file-download-action.ts
@@ -0,0 +1,73 @@
+'use client';
+
+import type { HookSafeActionFn } from 'next-safe-action/hooks';
+
+import { useActionWithToast } from './use-action-with-toast';
+
+interface FileDownloadData {
+ success: boolean;
+ error?: string;
+ data?: {
+ content: string;
+ filename: string;
+ mimeType: string;
+ };
+ [key: string]: unknown;
+}
+
+interface UseFileDownloadOptions {
+ successMessage?: string;
+ errorMessage?: string;
+}
+
+function downloadBlob(blob: Blob, filename: string) {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+}
+
+function isBase64(str: string): boolean {
+ // Text content (CSV, XML) won't start with common base64 patterns
+ // Base64 encoded binary content is typically longer and matches the pattern
+ return /^[A-Za-z0-9+/]+=*$/.test(str.slice(0, 100)) && str.length > 200;
+}
+
+/**
+ * Wraps a server action that returns a file download payload.
+ * Automatically creates a blob and triggers download.
+ *
+ * Expects the action to return: { success: true, data: { content, filename, mimeType } }
+ */
+export function useFileDownloadAction(
+ action: HookSafeActionFn,
+ options: UseFileDownloadOptions = {},
+) {
+ return useActionWithToast(action, {
+ successMessage: options.successMessage ?? 'Export heruntergeladen',
+ errorMessage: options.errorMessage ?? 'Export fehlgeschlagen',
+ onSuccess: (data) => {
+ const file = data.data;
+ if (!file?.content) return;
+
+ let blob: Blob;
+
+ if (isBase64(file.content)) {
+ // Binary content (Excel, PDF): decode base64
+ const byteChars = atob(file.content);
+ const byteNums = new Uint8Array(byteChars.length);
+ for (let i = 0; i < byteChars.length; i++) {
+ byteNums[i] = byteChars.charCodeAt(i);
+ }
+ blob = new Blob([byteNums], { type: file.mimeType });
+ } else {
+ // Text content (CSV, XML): use directly
+ blob = new Blob([file.content], { type: file.mimeType });
+ }
+
+ downloadBlob(blob, file.filename);
+ },
+ });
+}
diff --git a/packages/ui/src/makerkit/list-toolbar.tsx b/packages/ui/src/makerkit/list-toolbar.tsx
new file mode 100644
index 000000000..b5bd2fdb1
--- /dev/null
+++ b/packages/ui/src/makerkit/list-toolbar.tsx
@@ -0,0 +1,119 @@
+'use client';
+
+import { useCallback } from 'react';
+
+import { useRouter, useSearchParams, usePathname } from 'next/navigation';
+
+import { Search } from 'lucide-react';
+
+import { Button } from '../shadcn/button';
+import { Input } from '../shadcn/input';
+
+interface FilterOption {
+ value: string;
+ label: string;
+}
+
+interface FilterConfig {
+ /** URL param name (e.g. "status") */
+ param: string;
+ /** Display label (e.g. "Status") */
+ label: string;
+ /** Dropdown options including an "all" default */
+ options: FilterOption[];
+}
+
+interface ListToolbarProps {
+ /** Placeholder text for search input */
+ searchPlaceholder?: string;
+ /** URL param name for search (default: "q") */
+ searchParam?: string;
+ /** Filter dropdowns */
+ filters?: FilterConfig[];
+ /** Whether to show search input (default: true) */
+ showSearch?: boolean;
+}
+
+/**
+ * Reusable toolbar for list pages with search and filter dropdowns.
+ * Syncs state with URL search params so server components can read them.
+ * Resets to page 1 on any filter/search change.
+ */
+export function ListToolbar({
+ searchPlaceholder = 'Suchen...',
+ searchParam = 'q',
+ filters = [],
+ showSearch = true,
+}: ListToolbarProps) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ const updateParams = useCallback(
+ (updates: Record) => {
+ const params = new URLSearchParams(searchParams.toString());
+ params.delete('page');
+ for (const [key, value] of Object.entries(updates)) {
+ if (value === null || value === '') {
+ params.delete(key);
+ } else {
+ params.set(key, value);
+ }
+ }
+ const qs = params.toString();
+ router.push(qs ? `${pathname}?${qs}` : pathname);
+ },
+ [router, pathname, searchParams],
+ );
+
+ const currentSearch = searchParams.get(searchParam) ?? '';
+
+ return (
+
+ {showSearch && (
+
+ )}
+
+ {filters.length > 0 && (
+
+ {filters.map((filter) => (
+
+ ))}
+
+ )}
+
+ );
+}