From db4e19c3af0687b0c6f481bbcf4dec13d28c647d Mon Sep 17 00:00:00 2001 From: "T. Zehetbauer" <125989630+4thTomost@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:02:55 +0200 Subject: [PATCH] feat: add invitations management and import wizard; enhance audit logging and member detail fetching --- apps/web/app/[locale]/admin/audit/page.tsx | 223 +++++- .../[account]/members-cms/[memberId]/page.tsx | 16 +- .../invitations/invitations-view.tsx | 263 +++++++ .../members-cms/invitations/page.tsx | 48 ++ .../[moduleId]/import/import-wizard.tsx | 442 +++++++++++ .../modules/[moduleId]/import/page.tsx | 104 +-- apps/web/package.json | 2 + .../src/components/member-detail-view.tsx | 723 +++++++++++++++++- .../src/server/actions/record-actions.ts | 99 +++ .../src/server/services/audit.service.ts | 31 + 10 files changed, 1847 insertions(+), 104 deletions(-) create mode 100644 apps/web/app/[locale]/home/[account]/members-cms/invitations/invitations-view.tsx create mode 100644 apps/web/app/[locale]/home/[account]/members-cms/invitations/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/modules/[moduleId]/import/import-wizard.tsx diff --git a/apps/web/app/[locale]/admin/audit/page.tsx b/apps/web/app/[locale]/admin/audit/page.tsx index 4c4ec5a98..c45c646fe 100644 --- a/apps/web/app/[locale]/admin/audit/page.tsx +++ b/apps/web/app/[locale]/admin/audit/page.tsx @@ -1,19 +1,214 @@ -export default async function AdminAuditPage() { - return ( -
-
-

Protokoll

-

- Mandantenübergreifendes Änderungsprotokoll -

-
+import { AdminGuard } from '@kit/admin/components/admin-guard'; +import { createModuleBuilderApi } from '@kit/module-builder/api'; +import { formatDateTime } from '@kit/shared/dates'; +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; +import { Badge } from '@kit/ui/badge'; +import { Button } from '@kit/ui/button'; +import { PageBody, PageHeader } from '@kit/ui/page'; -
-

- Alle Datenänderungen (Erstellen, Ändern, Löschen, Sperren) über alle - Mandanten hinweg. Filtert nach Zeitraum, Benutzer, Tabelle und Aktion. -

+interface SearchParams { + action?: string; + table?: string; + page?: string; +} + +interface AdminAuditPageProps { + searchParams: Promise; +} + +const ACTION_LABELS: Record = { + insert: 'Erstellen', + update: 'Ändern', + delete: 'Löschen', + lock: 'Sperren', +}; + +const ACTION_COLORS: Record< + string, + 'default' | 'secondary' | 'destructive' | 'outline' +> = { + insert: 'default', + update: 'secondary', + delete: 'destructive', + lock: 'outline', +}; + +async function AuditPage(props: AdminAuditPageProps) { + const searchParams = await props.searchParams; + const client = getSupabaseServerAdminClient(); + const api = createModuleBuilderApi(client); + + const page = searchParams.page ? parseInt(searchParams.page, 10) : 1; + + const result = await api.audit.query({ + action: searchParams.action || undefined, + tableName: searchParams.table || undefined, + page, + pageSize: 50, + }); + + const totalPages = Math.ceil(result.total / result.pageSize); + + return ( + + + +
+ {/* Filters */} + + + {/* Results table */} +
+ + + + + + + + + + + + {result.data.length === 0 ? ( + + + + ) : ( + result.data.map((entry) => ( + + + + + + + + )) + )} + +
ZeitpunktAktionTabelleDatensatz-IDBenutzer-ID
+ Keine Einträge gefunden. +
+ {formatDateTime(entry.created_at)} + + + {ACTION_LABELS[entry.action as string] ?? + String(entry.action)} + + + {String(entry.table_name)} + + {String(entry.record_id).slice(0, 8)}... + + {String(entry.user_id).slice(0, 8)}... +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Seite {page} von {totalPages} ({result.total} Einträge) +

+
+ {page > 1 && ( + + )} + {page < totalPages && ( + + )} +
+
+ )}
+
+ ); +} + +function AuditFilters({ + currentAction, + currentTable, +}: { + currentAction?: string; + currentTable?: string; +}) { + return ( +
+
+ + + + + +
); } + +function PaginationLink({ + page, + action, + table, + label, +}: { + page: number; + action?: string; + table?: string; + label: string; +}) { + const params = new URLSearchParams(); + params.set('page', String(page)); + if (action) params.set('action', action); + if (table) params.set('table', table); + + return ( + + + + ); +} + +export default AdminGuard(AuditPage); diff --git a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx index f3f74e8b0..fec93ecad 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx @@ -23,12 +23,26 @@ export default async function MemberDetailPage({ params }: Props) { const member = await api.getMember(memberId); if (!member) return
Mitglied nicht gefunden
; + // Fetch sub-entities in parallel + const [roles, honors, mandates] = await Promise.all([ + api.listMemberRoles(memberId), + api.listMemberHonors(memberId), + api.listMandates(memberId), + ]); + return ( - + ); } diff --git a/apps/web/app/[locale]/home/[account]/members-cms/invitations/invitations-view.tsx b/apps/web/app/[locale]/home/[account]/members-cms/invitations/invitations-view.tsx new file mode 100644 index 000000000..e8d6a707c --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/members-cms/invitations/invitations-view.tsx @@ -0,0 +1,263 @@ +'use client'; + +import { useState, useCallback } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Mail, XCircle, Send } from 'lucide-react'; + +import { + inviteMemberToPortal, + revokePortalInvitation, +} from '@kit/member-management/actions/member-actions'; +import { formatDate } from '@kit/shared/dates'; +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'; + +interface Invitation { + id: string; + member_id: string; + email: string; + status: string; + created_at: string; + expires_at: string; + accepted_at: string | null; +} + +interface MemberOption { + id: string; + first_name: string; + last_name: string; + email: string | null; +} + +interface InvitationsViewProps { + invitations: Invitation[]; + members: MemberOption[]; + accountId: string; + account: string; +} + +const STATUS_LABELS: Record = { + pending: 'Ausstehend', + accepted: 'Angenommen', + revoked: 'Widerrufen', + expired: 'Abgelaufen', +}; + +const STATUS_COLORS: Record< + string, + 'default' | 'secondary' | 'destructive' | 'outline' +> = { + pending: 'default', + accepted: 'secondary', + revoked: 'destructive', + expired: 'outline', +}; + +export function InvitationsView({ + invitations, + members, + accountId, + account, +}: InvitationsViewProps) { + const router = useRouter(); + const [showDialog, setShowDialog] = useState(false); + const [selectedMemberId, setSelectedMemberId] = useState(''); + const [email, setEmail] = useState(''); + + const { execute: executeInvite, isPending: isInviting } = useActionWithToast( + inviteMemberToPortal, + { + successMessage: 'Einladung gesendet', + onSuccess: () => { + setShowDialog(false); + setSelectedMemberId(''); + setEmail(''); + router.refresh(); + }, + }, + ); + + const { execute: executeRevoke, isPending: isRevoking } = useActionWithToast( + revokePortalInvitation, + { + successMessage: 'Einladung widerrufen', + onSuccess: () => router.refresh(), + }, + ); + + const handleInvite = useCallback(() => { + if (!selectedMemberId || !email) return; + executeInvite({ + memberId: selectedMemberId, + accountId, + email, + }); + }, [executeInvite, selectedMemberId, accountId, email]); + + const handleRevoke = useCallback( + (invitationId: string) => { + if (!window.confirm('Einladung wirklich widerrufen?')) return; + executeRevoke({ invitationId }); + }, + [executeRevoke], + ); + + // When a member is selected, pre-fill email + const handleMemberChange = useCallback( + (memberId: string) => { + setSelectedMemberId(memberId); + const member = members.find((m) => m.id === memberId); + if (member?.email) { + setEmail(member.email); + } + }, + [members], + ); + + return ( +
+ {/* Actions */} +
+ +
+ + {/* Send Invitation Dialog */} + {showDialog && ( + + + + + Einladung senden + + + +
+
+ + +
+
+ + setEmail(e.target.value)} + placeholder="E-Mail eingeben..." + data-test="invite-email-input" + className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm" + /> +
+
+ + +
+
+
+
+ )} + + {/* Invitations Table */} + + + Einladungen ({invitations.length}) + + + {invitations.length === 0 ? ( +
+ +

+ Keine Einladungen vorhanden +

+

+ Senden Sie die erste Einladung zum Mitgliederportal. +

+
+ ) : ( +
+ + + + + + + + + + + + {invitations.map((inv) => ( + + + + + + + + ))} + +
E-MailStatusErstelltLäuft abAktionen
{inv.email} + + {STATUS_LABELS[inv.status] ?? inv.status} + + + {formatDate(inv.created_at)} + + {formatDate(inv.expires_at)} + + {inv.status === 'pending' && ( + + )} +
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/members-cms/invitations/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/invitations/page.tsx new file mode 100644 index 000000000..2f06522b3 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/members-cms/invitations/page.tsx @@ -0,0 +1,48 @@ +import { createMemberManagementApi } from '@kit/member-management/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +import { AccountNotFound } from '~/components/account-not-found'; +import { CmsPageShell } from '~/components/cms-page-shell'; + +import { InvitationsView } from './invitations-view'; + +interface Props { + params: Promise<{ account: string }>; +} + +export default async function InvitationsPage({ params }: Props) { + const { account } = await params; + const client = getSupabaseServerClient(); + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + if (!acct) return ; + + const api = createMemberManagementApi(client); + const invitations = await api.listPortalInvitations(acct.id); + + // Fetch members for the "send invitation" dialog + const { data: members } = await client + .from('members') + .select('id, first_name, last_name, email') + .eq('account_id', acct.id) + .eq('status', 'active') + .order('last_name'); + + return ( + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/import/import-wizard.tsx b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/import/import-wizard.tsx new file mode 100644 index 000000000..7f732fd2a --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/import/import-wizard.tsx @@ -0,0 +1,442 @@ +'use client'; + +import { useState, useCallback } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { + Upload, + ArrowRight, + ArrowLeft, + CheckCircle, + AlertTriangle, +} from 'lucide-react'; +import Papa from 'papaparse'; + +import { bulkImportRecords } from '@kit/module-builder/actions/record-actions'; +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'; + +interface ImportWizardProps { + moduleId: string; + accountId: string; + fields: Array<{ name: string; display_name: string }>; + accountSlug: string; +} + +type Step = 'upload' | 'mapping' | 'preview' | 'importing' | 'done'; + +interface DryRunResult { + totalRows: number; + validRows: number; + errorCount: number; + errors: Array<{ row: number; field: string; message: string }>; +} + +export function ImportWizard({ + moduleId, + accountId, + fields, + accountSlug, +}: ImportWizardProps) { + const router = useRouter(); + const [step, setStep] = useState('upload'); + const [headers, setHeaders] = useState([]); + const [rows, setRows] = useState>>([]); + const [mapping, setMapping] = useState>({}); + const [dryRunResult, setDryRunResult] = useState(null); + const [importedCount, setImportedCount] = useState(0); + + const { execute: executeBulkImport, isPending } = useActionWithToast( + bulkImportRecords, + { + successMessage: 'Import erfolgreich', + onSuccess: (data) => { + if (data.data) { + if ('imported' in data.data) { + setImportedCount((data.data as { imported: number }).imported); + setStep('done'); + } else { + // dry run result + setDryRunResult(data.data as unknown as DryRunResult); + } + } + }, + }, + ); + + // Step 1: Parse CSV file + const handleFileUpload = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + Papa.parse(file, { + header: true, + skipEmptyLines: true, + complete: (result) => { + const parsed = result.data as Array>; + if (parsed.length === 0) return; + + const csvHeaders = result.meta.fields ?? []; + setHeaders(csvHeaders); + setRows(parsed); + + // Auto-map by exact display_name match + const autoMap: Record = {}; + for (const field of fields) { + const match = csvHeaders.find( + (h) => + h.toLowerCase().trim() === + field.display_name.toLowerCase().trim(), + ); + if (match) autoMap[field.name] = match; + } + setMapping(autoMap); + setStep('mapping'); + }, + }); + }, + [fields], + ); + + // Build mapped records from rows + mapping + const buildMappedRecords = useCallback(() => { + return rows.map((row) => { + const record: Record = {}; + for (const field of fields) { + const sourceCol = mapping[field.name]; + if (sourceCol && row[sourceCol] !== undefined) { + record[field.name] = row[sourceCol]; + } + } + return record; + }); + }, [rows, mapping, fields]); + + // Step 3: Dry run + const handleDryRun = useCallback(() => { + const records = buildMappedRecords(); + executeBulkImport({ + moduleId, + accountId, + records, + dryRun: true, + }); + }, [buildMappedRecords, executeBulkImport, moduleId, accountId]); + + // Step 4: Actual import + const handleImport = useCallback(() => { + setStep('importing'); + const records = buildMappedRecords(); + executeBulkImport({ + moduleId, + accountId, + records, + dryRun: false, + }); + }, [buildMappedRecords, executeBulkImport, moduleId, accountId]); + + // Preview: first 5 mapped rows + const previewRows = buildMappedRecords().slice(0, 5); + const mappedFields = fields.filter((f) => mapping[f.name]); + + const stepIndex = [ + 'upload', + 'mapping', + 'preview', + 'importing', + 'done', + ].indexOf(step); + + return ( +
+ {/* Step indicator */} +
+ {['Datei hochladen', 'Spalten zuordnen', 'Vorschau', 'Importieren'].map( + (label, i) => ( +
+
= i + ? 'bg-primary text-primary-foreground' + : 'bg-muted text-muted-foreground' + }`} + > + {i + 1} +
+ = i ? 'font-semibold' : 'text-muted-foreground'}`} + > + {label} + + {i < 3 && ( + + )} +
+ ), + )} +
+ + {/* Step 1: Upload */} + {step === 'upload' && ( + + + + + Datei hochladen + + + +
+ +

CSV-Datei auswählen

+

+ Komma- oder Semikolon-getrennt, UTF-8 +

+ +
+ +
+

+ Verfügbare Zielfelder: +

+
+ {fields.map((field) => ( + + {field.display_name} + + ))} +
+
+
+
+ )} + + {/* Step 2: Column Mapping */} + {step === 'mapping' && ( + + + Spalten zuordnen + + +

+ {rows.length} Zeilen erkannt. Ordnen Sie die CSV-Spalten den + Modulfeldern zu. +

+
+ {fields.map((field) => ( +
+ + {field.display_name} + + + + {mapping[field.name] && rows[0] && ( + + z.B. "{rows[0][mapping[field.name]!]}" + + )} +
+ ))} +
+
+ + +
+
+
+ )} + + {/* Step 3: Preview + Dry Run */} + {step === 'preview' && ( + + + Vorschau ({rows.length} Einträge) + + + {mappedFields.length === 0 ? ( +
+ +

+ Keine Spalten zugeordnet. Bitte gehen Sie zurück und ordnen + Sie mindestens eine Spalte zu. +

+
+ ) : ( + <> +
+ + + + + {mappedFields.map((f) => ( + + ))} + + + + {previewRows.map((row, i) => ( + + + {mappedFields.map((f) => ( + + ))} + + ))} + +
# + {f.display_name} +
{i + 1} + {String(row[f.name] ?? '—')} +
+
+ {rows.length > 5 && ( +

+ ... und {rows.length - 5} weitere Einträge +

+ )} + + )} + + {/* Dry Run */} +
+ +
+ + {dryRunResult && ( +
+
+ + {dryRunResult.validRows} gültig + + {dryRunResult.errorCount > 0 && ( + + {dryRunResult.errorCount} Fehler + + )} +
+ {dryRunResult.errors.length > 0 && ( +
+ {dryRunResult.errors.map((err, i) => ( +

+ Zeile {err.row}: {err.field} — {err.message} +

+ ))} +
+ )} +
+ )} + +
+ + +
+
+
+ )} + + {/* Step 4: Importing */} + {step === 'importing' && ( + + +
+

Importiere Einträge...

+

+ Bitte warten Sie, bis der Import abgeschlossen ist. +

+ + + )} + + {/* Done */} + {step === 'done' && ( + + + +

Import abgeschlossen

+
+ {importedCount} importiert +
+
+ +
+
+
+ )} +
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/import/page.tsx b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/import/page.tsx index 89a9c4b0d..7c1d2217b 100644 --- a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/import/page.tsx +++ b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/import/page.tsx @@ -1,12 +1,10 @@ -import { Upload, ArrowRight, CheckCircle } from 'lucide-react'; - import { createModuleBuilderApi } from '@kit/module-builder/api'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; -import { Button } from '@kit/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { ImportWizard } from './import-wizard'; + interface ImportPageProps { params: Promise<{ account: string; moduleId: string }>; } @@ -19,92 +17,30 @@ export default async function ImportPage({ params }: ImportPageProps) { const moduleWithFields = await api.modules.getModuleWithFields(moduleId); if (!moduleWithFields) return
Modul nicht gefunden
; - const fields = moduleWithFields.fields ?? []; + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return
Account nicht gefunden
; + + const fields = (moduleWithFields.fields ?? []).map((f) => ({ + name: String(f.name), + display_name: String(f.display_name), + })); return ( -
- {/* Step indicator */} -
- {[ - 'Datei hochladen', - 'Spalten zuordnen', - 'Vorschau', - 'Importieren', - ].map((step, i) => ( -
-
- {i + 1} -
- - {step} - - {i < 3 && ( - - )} -
- ))} -
- - {/* Upload Step */} - - - - - Datei hochladen - - - -
- -

- CSV oder Excel-Datei hierher ziehen -

-

- oder klicken zum Auswählen -

- -
- -
-

- Verfügbare Zielfelder: -

-
- {fields.map((field) => ( - - {field.display_name} - - ))} -
-
- -
- -
-
-
-
+
); } diff --git a/apps/web/package.json b/apps/web/package.json index 12e2d774d..d390861f6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -73,6 +73,7 @@ "next-safe-action": "catalog:", "next-sitemap": "catalog:", "next-themes": "catalog:", + "papaparse": "catalog:", "react": "catalog:", "react-dom": "catalog:", "react-hook-form": "catalog:", @@ -91,6 +92,7 @@ "@kit/verbandsverwaltung": "workspace:*", "@next/bundle-analyzer": "catalog:", "@tailwindcss/postcss": "catalog:", + "@types/papaparse": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "babel-plugin-react-compiler": "catalog:", diff --git a/packages/features/member-management/src/components/member-detail-view.tsx b/packages/features/member-management/src/components/member-detail-view.tsx index 8a8decb6b..4cab585c6 100644 --- a/packages/features/member-management/src/components/member-detail-view.tsx +++ b/packages/features/member-management/src/components/member-detail-view.tsx @@ -1,17 +1,17 @@ 'use client'; -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAction } from 'next-safe-action/hooks'; -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 { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { toast } from '@kit/ui/sonner'; +import { useActionWithToast } from '@kit/ui/use-action-with-toast'; import { STATUS_LABELS, @@ -21,12 +21,51 @@ import { computeAge, computeMembershipYears, } from '../lib/member-utils'; -import { deleteMember, updateMember } from '../server/actions/member-actions'; +import { + deleteMember, + updateMember, + createMemberRole, + deleteMemberRole, + createMemberHonor, + deleteMemberHonor, + createMandate, + revokeMandate, +} from '../server/actions/member-actions'; + +interface MemberRole { + id: string; + role_name: string; + from_date: string | null; + until_date: string | null; + is_active: boolean; +} + +interface MemberHonor { + id: string; + honor_name: string; + honor_date: string | null; + description: string | null; +} + +interface SepaMandate { + id: string; + mandate_reference: string; + iban: string; + bic: string | null; + account_holder: string; + mandate_date: string; + status: string; + is_primary: boolean; + sequence: string; +} interface MemberDetailViewProps { member: Record; account: string; accountId: string; + roles?: MemberRole[]; + honors?: MemberHonor[]; + mandates?: SepaMandate[]; } function DetailRow({ @@ -48,6 +87,9 @@ export function MemberDetailView({ member, account, accountId, + roles = [], + honors = [], + mandates = [], }: MemberDetailViewProps) { const router = useRouter(); @@ -57,8 +99,6 @@ export function MemberDetailView({ const lastName = String(member.last_name ?? ''); const fullName = `${firstName} ${lastName}`.trim(); - const form = useForm(); - const { execute: executeDelete, isPending: isDeleting } = useAction( deleteMember, { @@ -252,6 +292,23 @@ export function MemberDetailView({
+ {/* Roles Section */} + + + {/* Honors Section */} + + + {/* Mandates Section */} + + {/* Back */}
); } + +/* ─── Roles Section ─── */ + +function RolesSection({ + roles, + memberId, + accountId, +}: { + roles: MemberRole[]; + memberId: string; + accountId: string; +}) { + const router = useRouter(); + const [showForm, setShowForm] = useState(false); + const [roleName, setRoleName] = useState(''); + const [fromDate, setFromDate] = useState(''); + const [untilDate, setUntilDate] = useState(''); + + const { execute: executeCreate, isPending: isCreating } = useActionWithToast( + createMemberRole, + { + successMessage: 'Funktion erstellt', + onSuccess: () => { + setShowForm(false); + setRoleName(''); + setFromDate(''); + setUntilDate(''); + router.refresh(); + }, + }, + ); + + const { execute: executeDeleteRole, isPending: isDeletingRole } = + useActionWithToast(deleteMemberRole, { + successMessage: 'Funktion gelöscht', + onSuccess: () => router.refresh(), + }); + + const handleCreate = useCallback(() => { + if (!roleName.trim()) return; + executeCreate({ + memberId, + accountId, + roleName: roleName.trim(), + fromDate: fromDate || undefined, + untilDate: untilDate || undefined, + }); + }, [executeCreate, memberId, accountId, roleName, fromDate, untilDate]); + + const handleDeleteRole = useCallback( + (roleId: string) => { + if (!window.confirm('Funktion wirklich löschen?')) return; + executeDeleteRole({ roleId }); + }, + [executeDeleteRole], + ); + + return ( + + +
+ Funktionen ({roles.length}) + +
+
+ + {showForm && ( +
+
+
+ + setRoleName(e.target.value)} + placeholder="z.B. Kassier" + data-test="role-name-input" + className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm" + /> +
+
+ + setFromDate(e.target.value)} + data-test="role-from-date" + className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm" + /> +
+
+ + setUntilDate(e.target.value)} + data-test="role-until-date" + className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm" + /> +
+
+
+ + +
+
+ )} + + {roles.length === 0 && !showForm ? ( +

+ Keine Funktionen vorhanden. +

+ ) : ( + roles.length > 0 && ( +
+ + + + + + + + + + + {roles.map((role) => ( + + + + + + + ))} + +
BezeichnungVonBisAktionen
{role.role_name} + {role.from_date ? formatDate(role.from_date) : '—'} + + {role.until_date ? formatDate(role.until_date) : '—'} + + +
+
+ ) + )} +
+
+ ); +} + +/* ─── Honors Section ─── */ + +function HonorsSection({ + honors, + memberId, + accountId, +}: { + honors: MemberHonor[]; + memberId: string; + accountId: string; +}) { + const router = useRouter(); + const [showForm, setShowForm] = useState(false); + const [honorName, setHonorName] = useState(''); + const [honorDate, setHonorDate] = useState(''); + const [description, setDescription] = useState(''); + + const { execute: executeCreate, isPending: isCreating } = useActionWithToast( + createMemberHonor, + { + successMessage: 'Ehrung erstellt', + onSuccess: () => { + setShowForm(false); + setHonorName(''); + setHonorDate(''); + setDescription(''); + router.refresh(); + }, + }, + ); + + const { execute: executeDeleteHonor, isPending: isDeletingHonor } = + useActionWithToast(deleteMemberHonor, { + successMessage: 'Ehrung gelöscht', + onSuccess: () => router.refresh(), + }); + + const handleCreate = useCallback(() => { + if (!honorName.trim()) return; + executeCreate({ + memberId, + accountId, + honorName: honorName.trim(), + honorDate: honorDate || undefined, + description: description || undefined, + }); + }, [executeCreate, memberId, accountId, honorName, honorDate, description]); + + const handleDeleteHonor = useCallback( + (honorId: string) => { + if (!window.confirm('Ehrung wirklich löschen?')) return; + executeDeleteHonor({ honorId }); + }, + [executeDeleteHonor], + ); + + return ( + + +
+ Ehrungen ({honors.length}) + +
+
+ + {showForm && ( +
+
+
+ + setHonorName(e.target.value)} + placeholder="z.B. Ehrennadel in Gold" + data-test="honor-name-input" + className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm" + /> +
+
+ + setHonorDate(e.target.value)} + data-test="honor-date-input" + className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm" + /> +
+
+ + setDescription(e.target.value)} + placeholder="Optional" + data-test="honor-description-input" + className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm" + /> +
+
+
+ + +
+
+ )} + + {honors.length === 0 && !showForm ? ( +

+ Keine Ehrungen vorhanden. +

+ ) : ( + honors.length > 0 && ( +
+ + + + + + + + + + + {honors.map((honor) => ( + + + + + + + ))} + +
BezeichnungDatumBeschreibungAktionen
{honor.honor_name} + {honor.honor_date ? formatDate(honor.honor_date) : '—'} + + {honor.description ?? '—'} + + +
+
+ ) + )} +
+
+ ); +} + +/* ─── Mandates Section ─── */ + +function MandatesSection({ + mandates, + memberId, + accountId, +}: { + mandates: SepaMandate[]; + memberId: string; + accountId: string; +}) { + const router = useRouter(); + const [showForm, setShowForm] = useState(false); + const [mandateRef, setMandateRef] = useState(''); + const [mandateIban, setMandateIban] = useState(''); + const [mandateBic, setMandateBic] = useState(''); + const [mandateHolder, setMandateHolder] = useState(''); + const [mandateDate, setMandateDate] = useState(''); + const [mandateSequence, setMandateSequence] = useState< + 'FRST' | 'RCUR' | 'FNAL' | 'OOFF' + >('RCUR'); + + const MANDATE_STATUS_LABELS: Record = { + active: 'Aktiv', + revoked: 'Widerrufen', + expired: 'Abgelaufen', + }; + + const MANDATE_STATUS_COLORS: Record< + string, + 'default' | 'secondary' | 'destructive' | 'outline' + > = { + active: 'default', + revoked: 'destructive', + expired: 'outline', + }; + + const { execute: executeCreate, isPending: isCreating } = useActionWithToast( + createMandate, + { + successMessage: 'Mandat erstellt', + onSuccess: () => { + setShowForm(false); + setMandateRef(''); + setMandateIban(''); + setMandateBic(''); + setMandateHolder(''); + setMandateDate(''); + setMandateSequence('RCUR'); + router.refresh(); + }, + }, + ); + + const { execute: executeRevoke, isPending: isRevoking } = useActionWithToast( + revokeMandate, + { + successMessage: 'Mandat widerrufen', + onSuccess: () => router.refresh(), + }, + ); + + const handleCreate = useCallback(() => { + if ( + !mandateRef.trim() || + !mandateIban.trim() || + !mandateHolder.trim() || + !mandateDate + ) + return; + executeCreate({ + memberId, + accountId, + mandateReference: mandateRef.trim(), + iban: mandateIban.trim(), + bic: mandateBic.trim() || undefined, + accountHolder: mandateHolder.trim(), + mandateDate, + sequence: mandateSequence, + }); + }, [ + executeCreate, + memberId, + accountId, + mandateRef, + mandateIban, + mandateBic, + mandateHolder, + mandateDate, + mandateSequence, + ]); + + const handleRevoke = useCallback( + (mandateId: string) => { + if (!window.confirm('Mandat wirklich widerrufen?')) return; + executeRevoke({ mandateId }); + }, + [executeRevoke], + ); + + return ( + + +
+ SEPA-Mandate ({mandates.length}) + +
+
+ + {showForm && ( +
+
+
+ + setMandateRef(e.target.value)} + placeholder="z.B. MAND-001" + data-test="mandate-ref-input" + className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm" + /> +
+
+ + setMandateIban(e.target.value)} + placeholder="DE..." + data-test="mandate-iban-input" + className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm" + /> +
+
+ + setMandateBic(e.target.value)} + placeholder="Optional" + data-test="mandate-bic-input" + className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm" + /> +
+
+ + setMandateHolder(e.target.value)} + placeholder="Name des Kontoinhabers" + data-test="mandate-holder-input" + className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm" + /> +
+
+ + setMandateDate(e.target.value)} + data-test="mandate-date-input" + className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm" + /> +
+
+ + +
+
+
+ + +
+
+ )} + + {mandates.length === 0 && !showForm ? ( +

+ Keine SEPA-Mandate vorhanden. +

+ ) : ( + mandates.length > 0 && ( +
+ + + + + + + + + + + + + {mandates.map((mandate) => ( + + + + + + + + + ))} + +
ReferenzIBANKontoinhaberDatumStatusAktionen
+ {mandate.mandate_reference} + {mandate.is_primary && ( + + Primär + + )} + + {formatIban(mandate.iban)} + {mandate.account_holder} + {formatDate(mandate.mandate_date)} + + + {MANDATE_STATUS_LABELS[mandate.status] ?? + mandate.status} + + + {mandate.status === 'active' && ( + + )} +
+
+ ) + )} +
+
+ ); +} diff --git a/packages/features/module-builder/src/server/actions/record-actions.ts b/packages/features/module-builder/src/server/actions/record-actions.ts index 5103a7275..3dec1250b 100644 --- a/packages/features/module-builder/src/server/actions/record-actions.ts +++ b/packages/features/module-builder/src/server/actions/record-actions.ts @@ -172,3 +172,102 @@ export const lockRecord = authActionClient return { success: true, data: record }; }); + +export const bulkImportRecords = authActionClient + .inputSchema( + z.object({ + moduleId: z.string().uuid(), + accountId: z.string().uuid(), + records: z.array(z.record(z.string(), z.unknown())).min(1).max(1000), + dryRun: z.boolean().default(false), + }), + ) + .action(async ({ parsedInput: input, ctx }) => { + const client = getSupabaseServerClient(); + const logger = await getLogger(); + const api = createModuleBuilderApi(client); + const userId = ctx.user.id; + + const moduleWithFields = await api.modules.getModuleWithFields( + input.moduleId, + ); + + if (!moduleWithFields) { + throw new Error('Module not found'); + } + + const { fields } = moduleWithFields; + const errors: Array<{ row: number; field: string; message: string }> = []; + const validRows: Array> = []; + + for (let i = 0; i < input.records.length; i++) { + const row = input.records[i]!; + const validation = validateRecordData( + row, + fields as Parameters[1], + ); + + if (!validation.success) { + for (const err of validation.errors) { + errors.push({ row: i + 1, field: err.field, message: err.message }); + } + } else { + validRows.push(row); + } + } + + if (input.dryRun) { + return { + success: true, + data: { + totalRows: input.records.length, + validRows: validRows.length, + errorCount: errors.length, + errors: errors.slice(0, 50), + }, + }; + } + + if (errors.length > 0) { + return { + success: false, + error: `${errors.length} Validierungsfehler in ${new Set(errors.map((e) => e.row)).size} Zeilen`, + validationErrors: errors.slice(0, 50).map((e) => ({ + field: `Zeile ${e.row}: ${e.field}`, + message: e.message, + })), + }; + } + + logger.info( + { + name: 'records.bulkImport', + moduleId: input.moduleId, + count: validRows.length, + }, + 'Bulk importing records...', + ); + + const insertData = validRows.map((row) => ({ + module_id: input.moduleId, + account_id: input.accountId, + data: row as any, + status: 'active' as const, + created_by: userId, + updated_by: userId, + })); + + const { error } = await client.from('module_records').insert(insertData); + + if (error) throw error; + + logger.info( + { name: 'records.bulkImport', count: validRows.length }, + 'Bulk import complete', + ); + + return { + success: true, + data: { imported: validRows.length }, + }; + }); diff --git a/packages/features/module-builder/src/server/services/audit.service.ts b/packages/features/module-builder/src/server/services/audit.service.ts index b56c7f39a..b19b34cea 100644 --- a/packages/features/module-builder/src/server/services/audit.service.ts +++ b/packages/features/module-builder/src/server/services/audit.service.ts @@ -34,5 +34,36 @@ export function createAuditService(client: SupabaseClient) { ); } }, + + async query(opts?: { + accountId?: string; + userId?: string; + tableName?: string; + action?: string; + page?: number; + pageSize?: number; + }) { + let q = client + .from('audit_log') + .select('*', { count: 'exact' }) + .order('created_at', { ascending: false }); + + if (opts?.accountId) q = q.eq('account_id', opts.accountId); + if (opts?.userId) q = q.eq('user_id', opts.userId); + if (opts?.tableName) q = q.eq('table_name', opts.tableName); + if (opts?.action) + q = q.eq( + 'action', + opts.action as 'insert' | 'update' | 'delete' | 'lock', + ); + + const page = opts?.page ?? 1; + const pageSize = opts?.pageSize ?? 50; + q = q.range((page - 1) * pageSize, page * pageSize - 1); + + const { data, error, count } = await q; + if (error) throw error; + return { data: data ?? [], total: count ?? 0, page, pageSize }; + }, }; }