From 7b078f298b69e6ccce97c3abfd036e766a85d5ce Mon Sep 17 00:00:00 2001 From: "T. Zehetbauer" <125989630+4thTomost@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:18:24 +0200 Subject: [PATCH] feat: enhance API response handling and add new components for module management --- .../[locale]/home/[account]/courses/page.tsx | 27 ++- .../home/[account]/finance/invoices/page.tsx | 3 +- .../[locale]/home/[account]/finance/page.tsx | 123 ++++++++++- .../home/[account]/finance/payments/page.tsx | 4 +- .../home/[account]/finance/sepa/page.tsx | 3 +- .../[account]/fischerei/catch-books/page.tsx | 28 +++ .../[account]/fischerei/competitions/page.tsx | 17 ++ .../home/[account]/fischerei/leases/page.tsx | 42 +++- .../home/[account]/fischerei/species/page.tsx | 20 ++ .../[account]/fischerei/stocking/page.tsx | 53 ++++- .../departments/create-department-dialog.tsx | 23 +- .../home/[account]/members-cms/page.tsx | 1 + .../modules/[moduleId]/[recordId]/page.tsx | 88 ++------ .../[recordId]/record-detail-client.tsx | 206 ++++++++++++++++++ .../modules/[moduleId]/_lib/filter-params.ts | 23 ++ .../modules/[moduleId]/module-search-bar.tsx | 58 +++++ .../[moduleId]/new/create-record-form.tsx | 48 ++++ .../[account]/modules/[moduleId]/new/page.tsx | 18 +- .../[account]/modules/[moduleId]/page.tsx | 32 ++- .../settings/delete-module-button.tsx | 74 +++++++ .../modules/[moduleId]/settings/page.tsx | 21 ++ .../dispatch-newsletter-button.tsx | 77 +++++++ .../newsletter/[campaignId]/page.tsx | 11 +- .../home/[account]/newsletter/page.tsx | 71 ++++-- .../[account]/newsletter/templates/page.tsx | 3 +- apps/web/app/[locale]/home/[account]/page.tsx | 6 +- .../settings/_components/settings-content.tsx | 83 +++---- apps/web/app/api/club/accept-invite/route.ts | 3 +- apps/web/app/api/club/contact/route.ts | 31 +-- .../web/app/api/club/course-register/route.ts | 44 ++-- apps/web/app/api/club/event-register/route.ts | 53 ++--- .../app/api/club/membership-apply/route.ts | 57 ++--- apps/web/app/api/club/newsletter/route.ts | 45 ++-- .../src/server/actions/finance-actions.ts | 11 +- packages/features/finance/src/server/api.ts | 75 ++++++- .../src/components/create-member-form.tsx | 70 +++++- .../src/components/members-data-table.tsx | 52 ++++- .../src/server/actions/member-actions.ts | 45 +++- packages/features/module-builder/package.json | 1 + .../src/server/actions/module-actions.ts | 12 +- .../src/server/actions/record-actions.ts | 46 +++- .../features/newsletter/src/server/api.ts | 66 +++++- .../src/components/create-page-form.tsx | 14 +- .../src/components/create-post-form.tsx | 8 +- .../src/components/site-editor.tsx | 9 +- .../src/components/site-settings-form.tsx | 8 +- .../site-builder/src/config/puck-config.tsx | 44 +++- .../src/components/create-club-form.tsx | 17 +- .../components/cross-org-member-search.tsx | 18 +- .../src/server/actions/hierarchy-actions.ts | 7 +- packages/next/package.json | 3 +- packages/next/src/routes/api-helpers.ts | 28 +++ packages/shared/package.json | 3 +- packages/shared/src/api-response.ts | 46 ++++ packages/ui/package.json | 5 +- .../ui/src/hooks/use-action-with-toast.ts | 67 ++++++ .../ui/src/hooks/use-file-download-action.ts | 73 +++++++ packages/ui/src/makerkit/list-toolbar.tsx | 119 ++++++++++ 58 files changed, 1845 insertions(+), 398 deletions(-) create mode 100644 apps/web/app/[locale]/home/[account]/modules/[moduleId]/[recordId]/record-detail-client.tsx create mode 100644 apps/web/app/[locale]/home/[account]/modules/[moduleId]/_lib/filter-params.ts create mode 100644 apps/web/app/[locale]/home/[account]/modules/[moduleId]/module-search-bar.tsx create mode 100644 apps/web/app/[locale]/home/[account]/modules/[moduleId]/new/create-record-form.tsx create mode 100644 apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/delete-module-button.tsx create mode 100644 apps/web/app/[locale]/home/[account]/newsletter/[campaignId]/dispatch-newsletter-button.tsx create mode 100644 packages/next/src/routes/api-helpers.ts create mode 100644 packages/shared/src/api-response.ts create mode 100644 packages/ui/src/hooks/use-action-with-toast.ts create mode 100644 packages/ui/src/hooks/use-file-download-action.ts create mode 100644 packages/ui/src/makerkit/list-toolbar.tsx diff --git a/apps/web/app/[locale]/home/[account]/courses/page.tsx b/apps/web/app/[locale]/home/[account]/courses/page.tsx index 4fdcef09a..d8d8e22d3 100644 --- a/apps/web/app/[locale]/home/[account]/courses/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/page.tsx @@ -16,6 +16,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Badge } from '@kit/ui/badge'; import { Button } from '@kit/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; +import { ListToolbar } from '@kit/ui/list-toolbar'; import { AccountNotFound } from '~/components/account-not-found'; import { CmsPageShell } from '~/components/cms-page-shell'; @@ -50,7 +51,12 @@ export default async function CoursesPage({ params, searchParams }: PageProps) { const page = Number(search.page) || 1; const [courses, stats] = await Promise.all([ - api.listCourses(acct.id, { page, pageSize: PAGE_SIZE }), + api.listCourses(acct.id, { + search: search.q as string, + status: search.status as string, + page, + pageSize: PAGE_SIZE, + }), api.getStatistics(acct.id), ]); @@ -95,6 +101,25 @@ export default async function CoursesPage({ params, searchParams }: PageProps) { /> + {/* Search & Filters */} + + {/* Table or Empty State */} {courses.data.length === 0 ? ( ; const api = createFinanceApi(client); - const invoices = await api.listInvoices(acct.id); + const invoicesResult = await api.listInvoices(acct.id); + const invoices = invoicesResult.data; return ( diff --git a/apps/web/app/[locale]/home/[account]/finance/page.tsx b/apps/web/app/[locale]/home/[account]/finance/page.tsx index 9e65b89a4..25cb7d1c0 100644 --- a/apps/web/app/[locale]/home/[account]/finance/page.tsx +++ b/apps/web/app/[locale]/home/[account]/finance/page.tsx @@ -1,6 +1,14 @@ import Link from 'next/link'; -import { Landmark, FileText, Euro, ArrowRight, Plus } from 'lucide-react'; +import { + Landmark, + FileText, + Euro, + ArrowRight, + Plus, + ChevronLeft, + ChevronRight, +} from 'lucide-react'; import { createFinanceApi } from '@kit/finance/api'; import { formatDate } from '@kit/shared/dates'; @@ -8,6 +16,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Badge } from '@kit/ui/badge'; import { Button } from '@kit/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; +import { ListToolbar } from '@kit/ui/list-toolbar'; import { AccountNotFound } from '~/components/account-not-found'; import { CmsPageShell } from '~/components/cms-page-shell'; @@ -20,12 +29,30 @@ import { INVOICE_STATUS_LABEL, } from '~/lib/status-badges'; +const PAGE_SIZE = 25; + interface PageProps { params: Promise<{ account: string }>; + searchParams: Promise>; } -export default async function FinancePage({ params }: PageProps) { +function buildQuery( + base: Record, + overrides: Record, +): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries({ ...base, ...overrides })) { + if (value !== undefined && value !== '') { + params.set(key, String(value)); + } + } + const qs = params.toString(); + return qs ? `?${qs}` : ''; +} + +export default async function FinancePage({ params, searchParams }: PageProps) { const { account } = await params; + const search = await searchParams; const client = getSupabaseServerClient(); const { data: acct } = await client @@ -36,13 +63,20 @@ export default async function FinancePage({ params }: PageProps) { if (!acct) return ; + const q = typeof search.q === 'string' ? search.q : undefined; + const status = typeof search.status === 'string' ? search.status : undefined; + const page = Math.max(1, Number(search.page) || 1); + const api = createFinanceApi(client); - const [batches, invoices] = await Promise.all([ - api.listBatches(acct.id), - api.listInvoices(acct.id), + const [batchesResult, invoicesResult] = await Promise.all([ + api.listBatches(acct.id, { search: q, status, page, pageSize: PAGE_SIZE }), + api.listInvoices(acct.id, { search: q, status, page, pageSize: PAGE_SIZE }), ]); + const batches = batchesResult.data; + const invoices = invoicesResult.data; + const openAmount = invoices .filter( (inv: Record) => @@ -54,6 +88,15 @@ export default async function FinancePage({ params }: PageProps) { 0, ); + // Use the larger of the two totals for pagination + const totalPages = Math.max( + batchesResult.totalPages, + invoicesResult.totalPages, + ); + const safePage = page; + + const queryBase = { q, status }; + return (
@@ -83,12 +126,12 @@ export default async function FinancePage({ params }: PageProps) {
} /> } />
+ {/* Toolbar */} + + {/* SEPA Batches */} - Letzte SEPA-Einzüge + Letzte SEPA-Einzüge ({batchesResult.total}) + + ) : ( + + )} + + + {safePage} / {totalPages} + + + {safePage < totalPages ? ( + + + + ) : ( + + )} +
+ + )}
); diff --git a/apps/web/app/[locale]/home/[account]/finance/payments/page.tsx b/apps/web/app/[locale]/home/[account]/finance/payments/page.tsx index d81f044e3..8a04967c3 100644 --- a/apps/web/app/[locale]/home/[account]/finance/payments/page.tsx +++ b/apps/web/app/[locale]/home/[account]/finance/payments/page.tsx @@ -35,10 +35,12 @@ export default async function PaymentsPage({ params }: PageProps) { const api = createFinanceApi(client); - const [batches, invoices] = await Promise.all([ + const [batchesResult, invoicesResult] = await Promise.all([ api.listBatches(acct.id), api.listInvoices(acct.id), ]); + const batches = batchesResult.data; + const invoices = invoicesResult.data; const paidInvoices = invoices.filter( (inv: Record) => inv.status === 'paid', diff --git a/apps/web/app/[locale]/home/[account]/finance/sepa/page.tsx b/apps/web/app/[locale]/home/[account]/finance/sepa/page.tsx index 96159c50c..9393f2554 100644 --- a/apps/web/app/[locale]/home/[account]/finance/sepa/page.tsx +++ b/apps/web/app/[locale]/home/[account]/finance/sepa/page.tsx @@ -36,7 +36,8 @@ export default async function SepaPage({ params }: PageProps) { if (!acct) return ; const api = createFinanceApi(client); - const batches = await api.listBatches(acct.id); + const batchesResult = await api.listBatches(acct.id); + const batches = batchesResult.data; return ( diff --git a/apps/web/app/[locale]/home/[account]/fischerei/catch-books/page.tsx b/apps/web/app/[locale]/home/[account]/fischerei/catch-books/page.tsx index 644499952..ebbb7181d 100644 --- a/apps/web/app/[locale]/home/[account]/fischerei/catch-books/page.tsx +++ b/apps/web/app/[locale]/home/[account]/fischerei/catch-books/page.tsx @@ -4,6 +4,7 @@ import { CatchBooksDataTable, } from '@kit/fischerei/components'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { ListToolbar } from '@kit/ui/list-toolbar'; import { AccountNotFound } from '~/components/account-not-found'; import { CmsPageShell } from '~/components/cms-page-shell'; @@ -28,7 +29,18 @@ export default async function CatchBooksPage({ params, searchParams }: Props) { const api = createFischereiApi(client); const page = Number(search.page) || 1; + + const currentYear = new Date().getFullYear(); + const yearOptions = [ + { value: '', label: 'Alle Jahre' }, + ...Array.from({ length: 4 }, (_, i) => ({ + value: String(currentYear - i), + label: String(currentYear - i), + })), + ]; + const result = await api.listCatchBooks(acct.id, { + search: search.q as string, year: search.year ? Number(search.year) : undefined, status: search.status as string, page, @@ -38,6 +50,22 @@ export default async function CatchBooksPage({ params, searchParams }: Props) { return ( + ({ + value: String(currentYear - i), + label: String(currentYear - i), + })), + ]; + const result = await api.listCompetitions(acct.id, { + year: yearParam, page, pageSize: 25, }); @@ -39,6 +52,10 @@ export default async function CompetitionsPage({ return ( + ({ + value: String(w.id), + label: String(w.name), + })), + ]; return ( @@ -44,6 +63,21 @@ export default async function LeasesPage({ params, searchParams }: Props) { Gewässerpachtverträge verwalten

+ Pachten ({result.total}) diff --git a/apps/web/app/[locale]/home/[account]/fischerei/species/page.tsx b/apps/web/app/[locale]/home/[account]/fischerei/species/page.tsx index eeb9a1d3e..7af5d8356 100644 --- a/apps/web/app/[locale]/home/[account]/fischerei/species/page.tsx +++ b/apps/web/app/[locale]/home/[account]/fischerei/species/page.tsx @@ -4,6 +4,7 @@ import { SpeciesDataTable, } from '@kit/fischerei/components'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { ListToolbar } from '@kit/ui/list-toolbar'; import { AccountNotFound } from '~/components/account-not-found'; import { CmsPageShell } from '~/components/cms-page-shell'; @@ -28,8 +29,13 @@ export default async function SpeciesPage({ params, searchParams }: Props) { const api = createFischereiApi(client); const page = Number(search.page) || 1; + const activeParam = search.active as string | undefined; + const active = + activeParam === 'true' ? true : activeParam === 'false' ? false : undefined; + const result = await api.listSpecies(acct.id, { search: search.q as string, + active, page, pageSize: 50, }); @@ -37,6 +43,20 @@ export default async function SpeciesPage({ params, searchParams }: Props) { return ( + ({ + value: String(currentYear - i), + label: String(currentYear - i), + })), + ]; + + const waterOptions = [ + { value: '', label: 'Alle Gewässer' }, + ...watersResult.data.map((w) => ({ + value: String(w.id), + label: String(w.name), + })), + ]; + + const speciesOptions = [ + { value: '', label: 'Alle Arten' }, + ...speciesResult.data.map((s) => ({ + value: String(s.id), + label: String(s.name), + })), + ]; return ( + { - if (data?.success) { - toast.success('Abteilung erstellt'); - setOpen(false); - setName(''); - setDescription(''); - router.refresh(); - } - }, - onError: ({ error }) => { - toast.error(error.serverError ?? 'Fehler beim Erstellen der Abteilung'); + const { execute, isPending } = useActionWithToast(createDepartment, { + successMessage: 'Abteilung erstellt', + errorMessage: 'Fehler beim Erstellen der Abteilung', + onSuccess: () => { + setOpen(false); + setName(''); + setDescription(''); + router.refresh(); }, }); diff --git a/apps/web/app/[locale]/home/[account]/members-cms/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/page.tsx index e42385b6b..c496543da 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/page.tsx @@ -45,6 +45,7 @@ export default async function MembersPage({ params, searchParams }: Props) { page={page} pageSize={PAGE_SIZE} account={account} + accountId={acct.id} duesCategories={(duesCategories ?? []).map( (c: Record) => ({ id: String(c.id), diff --git a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/[recordId]/page.tsx b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/[recordId]/page.tsx index 2380e3457..c731c1e10 100644 --- a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/[recordId]/page.tsx +++ b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/[recordId]/page.tsx @@ -1,17 +1,12 @@ -import Link from 'next/link'; - -import { Pencil, Trash2, Lock, Unlock } from 'lucide-react'; - import { createModuleBuilderApi } from '@kit/module-builder/api'; import { ModuleForm } from '@kit/module-builder/components'; -import { formatDate } from '@kit/shared/dates'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; -import { Badge } from '@kit/ui/badge'; -import { Button } from '@kit/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; +import { AccountNotFound } from '~/components/account-not-found'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { RecordDetailClient } from './record-detail-client'; + interface RecordDetailPageProps { params: Promise<{ account: string; moduleId: string; recordId: string }>; } @@ -23,6 +18,14 @@ export default async function RecordDetailPage({ const client = getSupabaseServerClient(); const api = createModuleBuilderApi(client); + const { data: accountData } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!accountData) return ; + const [moduleWithFields, record] = await Promise.all([ api.modules.getModuleWithFields(moduleId), api.records.getRecord(recordId), @@ -49,68 +52,23 @@ export default async function RecordDetailPage({ } ).fields; - const data = (record.data ?? {}) as Record; - const isLocked = record.status === 'locked'; - return ( -
-
-
- - {String(record.status)} - - - Erstellt: {formatDate(record.created_at)} - -
-
- {isLocked ? ( - - ) : ( - - )} - -
-
- - - - - - Datensatz bearbeiten - - - - [0]['fields']} - initialData={data} - onSubmit={async () => {}} - isLoading={false} - /> - - -
+ [0]['fields']} + record={{ + id: record.id as string, + data: (record.data ?? {}) as Record, + status: record.status as string, + created_at: record.created_at as string, + }} + moduleId={moduleId} + accountId={accountData.id} + accountSlug={account} + />
); } diff --git a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/[recordId]/record-detail-client.tsx b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/[recordId]/record-detail-client.tsx new file mode 100644 index 000000000..cee71f64e --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/[recordId]/record-detail-client.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { useTransition } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Pencil, Trash2, Lock, Unlock } from 'lucide-react'; + +import { + updateRecord, + deleteRecord, + lockRecord, +} from '@kit/module-builder/actions/record-actions'; +import { ModuleForm } from '@kit/module-builder/components'; +import { formatDate } from '@kit/shared/dates'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@kit/ui/alert-dialog'; +import { Badge } from '@kit/ui/badge'; +import { Button } from '@kit/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; +import { useActionWithToast } from '@kit/ui/use-action-with-toast'; + +type FieldDef = Parameters[0]['fields'][number]; + +interface RecordDetailClientProps { + fields: FieldDef[]; + record: { + id: string; + data: Record; + status: string; + created_at: string; + }; + moduleId: string; + accountId: string; + accountSlug: string; +} + +export function RecordDetailClient({ + fields, + record, + moduleId, + accountId, + accountSlug, +}: RecordDetailClientProps) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const isLocked = record.status === 'locked'; + + const lockSuccessMessage = isLocked + ? 'Datensatz entsperrt' + : 'Datensatz gesperrt'; + + const { execute: execUpdate, isPending: isUpdating } = useActionWithToast( + updateRecord, + { + successMessage: 'Datensatz aktualisiert', + errorMessage: 'Fehler beim Aktualisieren', + onSuccess: () => { + startTransition(() => { + router.refresh(); + }); + }, + }, + ); + + const { execute: execDelete, isPending: isDeleting } = useActionWithToast( + deleteRecord, + { + successMessage: 'Datensatz gelöscht', + errorMessage: 'Fehler beim Löschen', + onSuccess: () => { + startTransition(() => { + router.push(`/home/${accountSlug}/modules/${moduleId}`); + }); + }, + }, + ); + + const { execute: execLock, isPending: isLocking } = useActionWithToast( + lockRecord, + { + successMessage: lockSuccessMessage, + errorMessage: 'Fehler beim Sperren', + onSuccess: () => { + startTransition(() => { + router.refresh(); + }); + }, + }, + ); + + const isBusy = isUpdating || isDeleting || isLocking || isPending; + + return ( +
+
+
+ + {record.status} + + + Erstellt: {formatDate(record.created_at)} + +
+
+ + + + + + + + + Datensatz löschen? + + Dieser Datensatz wird unwiderruflich gelöscht. Diese Aktion + kann nicht rückgängig gemacht werden. + + + + Abbrechen + + execDelete({ + recordId: record.id, + hard: false, + accountId, + }) + } + > + Löschen + + + + +
+
+ + + + + + Datensatz bearbeiten + + + + { + execUpdate({ + recordId: record.id, + data, + accountId, + }); + }} + isLoading={isBusy} + /> + + +
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/_lib/filter-params.ts b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/_lib/filter-params.ts new file mode 100644 index 000000000..4333173d6 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/_lib/filter-params.ts @@ -0,0 +1,23 @@ +interface FilterValue { + field: string; + operator: string; + value: string; +} + +export function encodeFilters(filters: FilterValue[]): string { + return filters + .map((f) => `${f.field}:${f.operator}:${encodeURIComponent(f.value)}`) + .join(','); +} + +export function decodeFilters(raw: string | undefined | null): FilterValue[] { + if (!raw) return []; + return raw.split(',').map((segment) => { + const [field, operator, ...rest] = segment.split(':'); + return { + field: field ?? '', + operator: operator ?? 'eq', + value: decodeURIComponent(rest.join(':')), + }; + }); +} diff --git a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/module-search-bar.tsx b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/module-search-bar.tsx new file mode 100644 index 000000000..8beaf5cca --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/module-search-bar.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useCallback } from 'react'; + +import { useRouter, useSearchParams, usePathname } from 'next/navigation'; + +import { ModuleSearch } from '@kit/module-builder/components'; + +import { encodeFilters, decodeFilters } from './_lib/filter-params'; + +interface FieldOption { + name: string; + display_name: string; + show_in_filter: boolean; + show_in_search: boolean; +} + +interface ModuleSearchBarProps { + fields: FieldOption[]; +} + +export function ModuleSearchBar({ fields }: ModuleSearchBarProps) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const currentSearch = searchParams.get('q') ?? ''; + const currentFilters = decodeFilters(searchParams.get('f') ?? ''); + + const updateParams = useCallback( + (updates: Record) => { + const params = new URLSearchParams(searchParams.toString()); + // Reset to page 1 when search/filters change + params.delete('page'); + for (const [key, value] of Object.entries(updates)) { + if (value === null || value === '') { + params.delete(key); + } else { + params.set(key, value); + } + } + router.push(`${pathname}?${params.toString()}`); + }, + [router, pathname, searchParams], + ); + + return ( + updateParams({ q: search || null })} + onFilter={(filters) => + updateParams({ f: filters.length > 0 ? encodeFilters(filters) : null }) + } + /> + ); +} diff --git a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/new/create-record-form.tsx b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/new/create-record-form.tsx new file mode 100644 index 000000000..5c3a7844c --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/new/create-record-form.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useTransition } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { createRecord } from '@kit/module-builder/actions/record-actions'; +import { ModuleForm } from '@kit/module-builder/components'; +import { useActionWithToast } from '@kit/ui/use-action-with-toast'; + +type FieldDef = Parameters[0]['fields'][number]; + +interface CreateRecordFormProps { + fields: FieldDef[]; + moduleId: string; + accountId: string; + accountSlug: string; +} + +export function CreateRecordForm({ + fields, + moduleId, + accountId, + accountSlug, +}: CreateRecordFormProps) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + const { execute, isPending: isExecuting } = useActionWithToast(createRecord, { + successMessage: 'Datensatz erstellt', + errorMessage: 'Fehler beim Erstellen', + onSuccess: () => { + startTransition(() => { + router.push(`/home/${accountSlug}/modules/${moduleId}`); + }); + }, + }); + + return ( + { + execute({ moduleId, accountId, data }); + }} + isLoading={isExecuting || isPending} + /> + ); +} diff --git a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/new/page.tsx b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/new/page.tsx index 8e7ffe55a..8e0704e5c 100644 --- a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/new/page.tsx +++ b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/new/page.tsx @@ -2,8 +2,11 @@ import { createModuleBuilderApi } from '@kit/module-builder/api'; import { ModuleForm } from '@kit/module-builder/components'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { AccountNotFound } from '~/components/account-not-found'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { CreateRecordForm } from './create-record-form'; + interface NewRecordPageProps { params: Promise<{ account: string; moduleId: string }>; } @@ -13,6 +16,14 @@ export default async function NewRecordPage({ params }: NewRecordPageProps) { const client = getSupabaseServerClient(); const api = createModuleBuilderApi(client); + const { data: accountData } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!accountData) return ; + const moduleWithFields = await api.modules.getModuleWithFields(moduleId); if (!moduleWithFields) return
Modul nicht gefunden
; @@ -41,10 +52,11 @@ export default async function NewRecordPage({ params }: NewRecordPageProps) { title={`${String(moduleWithFields.display_name)} — Neuer Datensatz`} >
- [0]['fields']} - onSubmit={async () => {}} - isLoading={false} + moduleId={moduleId} + accountId={accountData.id} + accountSlug={account} />
diff --git a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/page.tsx b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/page.tsx index 81f0d9925..38e837fc1 100644 --- a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/page.tsx +++ b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/page.tsx @@ -1,5 +1,13 @@ +import Link from 'next/link'; + +import { Plus } from 'lucide-react'; + import { createModuleBuilderApi } from '@kit/module-builder/api'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { Button } from '@kit/ui/button'; + +import { decodeFilters } from './_lib/filter-params'; +import { ModuleSearchBar } from './module-search-bar'; interface ModuleDetailPageProps { params: Promise<{ account: string; moduleId: string }>; @@ -25,6 +33,8 @@ export default async function ModuleDetailPage({ const pageSize = Number(search.pageSize) || moduleWithFields.default_page_size || 25; + const filters = decodeFilters(search.f as string | undefined); + const result = await api.query.query({ moduleId, page, @@ -38,9 +48,20 @@ export default async function ModuleDetailPage({ (moduleWithFields.default_sort_direction as 'asc' | 'desc') ?? 'asc', search: (search.q as string) ?? undefined, - filters: [], + filters, }); + const fields = ( + moduleWithFields as unknown as { + fields: Array<{ + name: string; + display_name: string; + show_in_filter: boolean; + show_in_search: boolean; + }>; + } + ).fields; + return (
@@ -54,14 +75,21 @@ export default async function ModuleDetailPage({

)}
+
+ +
{result.pagination.total} Datensätze — Seite {result.pagination.page}{' '} von {result.pagination.totalPages}
- {/* Phase 3 will replace this with module-table component */}
           {JSON.stringify(result.data, null, 2)}
diff --git a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/delete-module-button.tsx b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/delete-module-button.tsx
new file mode 100644
index 000000000..d43ab4d82
--- /dev/null
+++ b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/delete-module-button.tsx
@@ -0,0 +1,74 @@
+'use client';
+
+import { useTransition } from 'react';
+
+import { useRouter } from 'next/navigation';
+
+import { Trash2 } from 'lucide-react';
+
+import { deleteModule } from '@kit/module-builder/actions/module-actions';
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+  AlertDialogTrigger,
+} from '@kit/ui/alert-dialog';
+import { Button } from '@kit/ui/button';
+import { useActionWithToast } from '@kit/ui/use-action-with-toast';
+
+interface DeleteModuleButtonProps {
+  moduleId: string;
+  moduleName: string;
+  accountSlug: string;
+}
+
+export function DeleteModuleButton({
+  moduleId,
+  moduleName,
+  accountSlug,
+}: DeleteModuleButtonProps) {
+  const router = useRouter();
+  const [isPending, startTransition] = useTransition();
+
+  const { execute, isPending: isDeleting } = useActionWithToast(deleteModule, {
+    successMessage: `Modul "${moduleName}" wurde archiviert`,
+    errorMessage: 'Fehler beim Löschen',
+    onSuccess: () => {
+      startTransition(() => {
+        router.push(`/home/${accountSlug}/modules`);
+      });
+    },
+  });
+
+  return (
+    
+      
+        
+      
+      
+        
+          Modul archivieren?
+          
+            Das Modul "{moduleName}" wird archiviert und ist nicht
+            mehr sichtbar. Diese Aktion kann durch einen Administrator
+            rückgängig gemacht werden.
+          
+        
+        
+          Abbrechen
+           execute({ moduleId })}>
+            Archivieren
+          
+        
+      
+    
+  );
+}
diff --git a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/page.tsx b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/page.tsx
index e4fd42ad3..3c35854eb 100644
--- a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/page.tsx
@@ -10,6 +10,8 @@ import { Label } from '@kit/ui/label';
 
 import { CmsPageShell } from '~/components/cms-page-shell';
 
+import { DeleteModuleButton } from './delete-module-button';
+
 interface ModuleSettingsPageProps {
   params: Promise<{ account: string; moduleId: string }>;
 }
@@ -178,6 +180,25 @@ export default async function ModuleSettingsPage({
             

+ + {/* Danger Zone */} + + + + Gefahrenbereich + + + +

+ Das Modul wird archiviert und ist nicht mehr sichtbar. +

+ +
+
); diff --git a/apps/web/app/[locale]/home/[account]/newsletter/[campaignId]/dispatch-newsletter-button.tsx b/apps/web/app/[locale]/home/[account]/newsletter/[campaignId]/dispatch-newsletter-button.tsx new file mode 100644 index 000000000..1c84b063e --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/newsletter/[campaignId]/dispatch-newsletter-button.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { useTransition } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Send } from 'lucide-react'; + +import { dispatchNewsletter } from '@kit/newsletter/actions/newsletter-actions'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@kit/ui/alert-dialog'; +import { Button } from '@kit/ui/button'; +import { useActionWithToast } from '@kit/ui/use-action-with-toast'; + +interface DispatchNewsletterButtonProps { + newsletterId: string; + recipientCount: number; +} + +export function DispatchNewsletterButton({ + newsletterId, + recipientCount, +}: DispatchNewsletterButtonProps) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + const { execute, isPending: isDispatching } = useActionWithToast( + dispatchNewsletter, + { + successMessage: 'Newsletter wird versendet', + errorMessage: 'Fehler beim Versenden', + onSuccess: () => { + startTransition(() => { + router.refresh(); + }); + }, + }, + ); + + return ( + + + + + + + Newsletter versenden? + + Der Newsletter wird an {recipientCount} Empfänger versendet. Dieser + Vorgang kann nicht rückgängig gemacht werden. + + + + Abbrechen + execute({ newsletterId })}> + Jetzt versenden + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/newsletter/[campaignId]/page.tsx b/apps/web/app/[locale]/home/[account]/newsletter/[campaignId]/page.tsx index b3a2da8ef..dd40fe410 100644 --- a/apps/web/app/[locale]/home/[account]/newsletter/[campaignId]/page.tsx +++ b/apps/web/app/[locale]/home/[account]/newsletter/[campaignId]/page.tsx @@ -5,7 +5,6 @@ import { ArrowLeft, Send, Users } from 'lucide-react'; import { createNewsletterApi } from '@kit/newsletter/api'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Badge } from '@kit/ui/badge'; -import { Button } from '@kit/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { AccountNotFound } from '~/components/account-not-found'; @@ -18,6 +17,8 @@ import { NEWSLETTER_RECIPIENT_STATUS_LABEL, } from '~/lib/status-badges'; +import { DispatchNewsletterButton } from './dispatch-newsletter-button'; + interface PageProps { params: Promise<{ account: string; campaignId: string }>; } @@ -99,10 +100,10 @@ export default async function NewsletterDetailPage({ params }: PageProps) { {/* Actions */} {status === 'draft' && (
- +
)} diff --git a/apps/web/app/[locale]/home/[account]/newsletter/page.tsx b/apps/web/app/[locale]/home/[account]/newsletter/page.tsx index 236f2c35f..21a4ee916 100644 --- a/apps/web/app/[locale]/home/[account]/newsletter/page.tsx +++ b/apps/web/app/[locale]/home/[account]/newsletter/page.tsx @@ -15,6 +15,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Badge } from '@kit/ui/badge'; import { Button } from '@kit/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; +import { ListToolbar } from '@kit/ui/list-toolbar'; import { AccountNotFound } from '~/components/account-not-found'; import { CmsPageShell } from '~/components/cms-page-shell'; @@ -32,6 +33,20 @@ interface PageProps { searchParams: Promise>; } +function buildQuery( + base: Record, + overrides: Record, +): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries({ ...base, ...overrides })) { + if (value !== undefined && value !== '') { + params.set(key, String(value)); + } + } + const qs = params.toString(); + return qs ? `?${qs}` : ''; +} + export default async function NewsletterPage({ params, searchParams, @@ -48,26 +63,34 @@ export default async function NewsletterPage({ if (!acct) return ; - const api = createNewsletterApi(client); - const allNewsletters = await api.listNewsletters(acct.id); + const q = typeof search.q === 'string' ? search.q : undefined; + const status = typeof search.status === 'string' ? search.status : undefined; + const page = Math.max(1, Number(search.page) || 1); - const sentCount = allNewsletters.filter( + const api = createNewsletterApi(client); + const result = await api.listNewsletters(acct.id, { + search: q, + status, + page, + pageSize: PAGE_SIZE, + }); + + const newsletters = result.data; + const totalItems = result.total; + const totalPages = result.totalPages; + const safePage = result.page; + + const sentCount = newsletters.filter( (n: Record) => n.status === 'sent', ).length; - const totalRecipients = allNewsletters.reduce( + const totalRecipients = newsletters.reduce( (sum: number, n: Record) => sum + (Number(n.total_recipients) || 0), 0, ); - // Pagination - const currentPage = Math.max(1, Number(search.page) || 1); - const totalItems = allNewsletters.length; - const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE)); - const safePage = Math.min(currentPage, totalPages); - const startIdx = (safePage - 1) * PAGE_SIZE; - const newsletters = allNewsletters.slice(startIdx, startIdx + PAGE_SIZE); + const queryBase = { q, status }; return ( @@ -108,6 +131,25 @@ export default async function NewsletterPage({ /> + {/* Toolbar */} + + {/* Table or Empty State */} {totalItems === 0 ? ( 1 && (

- {startIdx + 1}–{Math.min(startIdx + PAGE_SIZE, totalItems)}{' '} - von {totalItems} + Seite {safePage} von {totalPages}

{safePage > 1 ? (
+ + 0} + onOpenChange={() => setDuplicates([])} + > + + + Mögliches Duplikat gefunden + + Es wurden ähnliche Mitglieder gefunden: +
    + {duplicates.map((d, i) => ( +
  • {d.message}
  • + ))} +
+
+
+ + Abbrechen + { + router.push( + `/home/${account}/members-cms/${duplicates[0]?.id}`, + ); + }} + > + Zum bestehenden Mitglied + + +
+
); } 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 && ( +
{ + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const value = (formData.get('search') as string) ?? ''; + updateParams({ [searchParam]: value || null }); + }} + className="flex gap-2" + > + + +
+ )} + + {filters.length > 0 && ( +
+ {filters.map((filter) => ( + + ))} +
+ )} +
+ ); +}