feat: MyEasyCMS v2 — Full SaaS rebuild
Some checks failed
Workflow / ⚫️ Test (push) Has been cancelled
Workflow / ʦ TypeScript (push) Has been cancelled

Complete rebuild of 22-year-old PHP CMS as modern SaaS:

Database (15 migrations, 42+ tables):
- Foundation: account_settings, audit_log, GDPR register, cms_files
- Module Engine: modules, fields, records, permissions, relations + RPC
- Members: 45+ field member profiles, departments, roles, honors, SEPA mandates
- Courses: courses, sessions, categories, instructors, locations, attendance
- Bookings: rooms, guests, bookings with availability
- Events: events, registrations, holiday passes
- Finance: SEPA batches/items (pain.008/001 XML), invoices
- Newsletter: campaigns, templates, recipients, subscriptions
- Site Builder: site_pages (Puck JSON), site_settings, cms_posts
- Portal Auth: member_portal_invitations, user linking

Feature Packages (9):
- @kit/module-builder — dynamic low-code CRUD engine
- @kit/member-management — 31 API methods, 21 actions, 8 components
- @kit/course-management, @kit/booking-management, @kit/event-management
- @kit/finance — SEPA XML generator + IBAN validator
- @kit/newsletter — campaigns + dispatch
- @kit/document-generator — PDF/Excel/Word
- @kit/site-builder — Puck visual editor, 15 blocks, public rendering

Pages (60+):
- Dashboard with real stats from all APIs
- Full CRUD for all 8 domains with react-hook-form + Zod
- Recharts statistics
- German i18n throughout
- Member portal with auth + invitation system
- Public club websites via Puck at /club/[slug]

Infrastructure:
- Dockerfile (multi-stage, standalone output)
- docker-compose.yml (Supabase self-hosted + Next.js)
- Kong API gateway config
- .env.production.example
This commit is contained in:
Zaid Marzguioui
2026-03-29 23:17:38 +02:00
parent 61ff48cb73
commit 1294caa7fa
120 changed files with 11013 additions and 1858 deletions

View File

@@ -12,13 +12,15 @@
"exports": {
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
"./components": "./src/components/index.ts"
"./components": "./src/components/index.ts",
"./actions/*": "./src/server/actions/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@hookform/resolvers": "catalog:",
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
@@ -29,6 +31,7 @@
"next": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
"react-hook-form": "catalog:",
"zod": "catalog:"
}
}

View File

@@ -0,0 +1,130 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { CreateBookingSchema } from '../schema/booking.schema';
import { createBooking } from '../server/actions/booking-actions';
interface Props {
accountId: string;
account: string;
rooms: Array<{ id: string; roomNumber: string; name?: string; pricePerNight: number }>;
}
export function CreateBookingForm({ accountId, account, rooms }: Props) {
const router = useRouter();
const form = useForm({
resolver: zodResolver(CreateBookingSchema),
defaultValues: {
accountId,
roomId: '',
checkIn: '',
checkOut: '',
adults: 1,
children: 0,
status: 'confirmed' as const,
totalPrice: 0,
notes: '',
},
});
const { execute, isPending } = useAction(createBooking, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Buchung erfolgreich erstellt');
router.push(`/home/${account}/bookings-cms`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Erstellen der Buchung');
},
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
<Card>
<CardHeader><CardTitle>Zimmer & Zeitraum</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="roomId" render={({ field }) => (
<FormItem><FormLabel>Zimmer *</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value=""> Zimmer wählen </option>
{rooms.map(r => (
<option key={r.id} value={r.id}>
{r.roomNumber}{r.name ? ` ${r.name}` : ''} ({r.pricePerNight} /Nacht)
</option>
))}
</select>
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="checkIn" render={({ field }) => (
<FormItem><FormLabel>Check-in *</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="checkOut" render={({ field }) => (
<FormItem><FormLabel>Check-out *</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Gäste</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="adults" render={({ field }) => (
<FormItem><FormLabel>Erwachsene *</FormLabel><FormControl>
<Input type="number" min={1} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="children" render={({ field }) => (
<FormItem><FormLabel>Kinder</FormLabel><FormControl>
<Input type="number" min={0} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Preis & Notizen</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="totalPrice" render={({ field }) => (
<FormItem><FormLabel>Gesamtpreis ()</FormLabel><FormControl>
<Input type="number" min={0} step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="status" render={({ field }) => (
<FormItem><FormLabel>Status</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="pending">Ausstehend</option>
<option value="confirmed">Bestätigt</option>
<option value="checked_in">Eingecheckt</option>
<option value="checked_out">Ausgecheckt</option>
<option value="cancelled">Storniert</option>
<option value="no_show">Nicht erschienen</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
<div className="sm:col-span-2">
<FormField control={form.control} name="notes" render={({ field }) => (
<FormItem><FormLabel>Notizen</FormLabel><FormControl>
<textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</FormControl><FormMessage /></FormItem>
)} />
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Buchung erstellen'}</Button>
</div>
</form>
</Form>
);
}

View File

@@ -1 +1 @@
export {};
export { CreateBookingForm } from './create-booking-form';

View File

@@ -0,0 +1,69 @@
'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
CreateBookingSchema,
CreateGuestSchema,
CreateRoomSchema,
} from '../../schema/booking.schema';
import { createBookingManagementApi } from '../api';
export const createBooking = authActionClient
.inputSchema(CreateBookingSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createBookingManagementApi(client);
logger.info({ name: 'booking.create' }, 'Creating booking...');
const result = await api.createBooking(input);
logger.info({ name: 'booking.create' }, 'Booking created');
return { success: true, data: result };
});
export const updateBookingStatus = authActionClient
.inputSchema(
z.object({
bookingId: z.string().uuid(),
status: z.string(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createBookingManagementApi(client);
logger.info({ name: 'booking.updateStatus' }, 'Updating booking status...');
const result = await api.updateBookingStatus(input.bookingId, input.status);
logger.info({ name: 'booking.updateStatus' }, 'Booking status updated');
return { success: true, data: result };
});
export const createRoom = authActionClient
.inputSchema(CreateRoomSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createBookingManagementApi(client);
logger.info({ name: 'booking.createRoom' }, 'Creating room...');
const result = await api.createRoom(input);
logger.info({ name: 'booking.createRoom' }, 'Room created');
return { success: true, data: result };
});
export const createGuest = authActionClient
.inputSchema(CreateGuestSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createBookingManagementApi(client);
logger.info({ name: 'booking.createGuest' }, 'Creating guest...');
const result = await api.createGuest(input);
logger.info({ name: 'booking.createGuest' }, 'Guest created');
return { success: true, data: result };
});

View File

@@ -84,5 +84,15 @@ export function createBookingManagementApi(client: SupabaseClient<Database>) {
if (error) throw error;
return data;
},
async createRoom(input: { accountId: string; roomNumber: string; name?: string; roomType?: string; capacity?: number; floor?: number; pricePerNight: number; description?: string }) {
const { data, error } = await client.from('rooms').insert({
account_id: input.accountId, room_number: input.roomNumber, name: input.name,
room_type: input.roomType ?? 'standard', capacity: input.capacity ?? 2,
floor: input.floor, price_per_night: input.pricePerNight, description: input.description,
}).select().single();
if (error) throw error;
return data;
},
};
}

View File

@@ -12,13 +12,15 @@
"exports": {
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
"./components": "./src/components/index.ts"
"./components": "./src/components/index.ts",
"./actions/*": "./src/server/actions/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@hookform/resolvers": "catalog:",
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
@@ -29,6 +31,7 @@
"next": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
"react-hook-form": "catalog:",
"zod": "catalog:"
}
}

View File

@@ -0,0 +1,147 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { CreateCourseSchema } from '../schema/course.schema';
import { createCourse } from '../server/actions/course-actions';
interface Props {
accountId: string;
account: string;
}
export function CreateCourseForm({ accountId, account }: Props) {
const router = useRouter();
const form = useForm({
resolver: zodResolver(CreateCourseSchema),
defaultValues: {
accountId,
courseNumber: '',
name: '',
description: '',
startDate: '',
endDate: '',
fee: 0,
reducedFee: 0,
capacity: 20,
minParticipants: 5,
status: 'planned' as const,
registrationDeadline: '',
notes: '',
},
});
const { execute, isPending } = useAction(createCourse, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Kurs erfolgreich erstellt');
router.push(`/home/${account}/courses-cms`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Erstellen des Kurses');
},
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
<Card>
<CardHeader><CardTitle>Grunddaten</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="courseNumber" render={({ field }) => (
<FormItem><FormLabel>Kursnummer</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="name" render={({ field }) => (
<FormItem><FormLabel>Kursname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<div className="sm:col-span-2">
<FormField control={form.control} name="description" render={({ field }) => (
<FormItem><FormLabel>Beschreibung</FormLabel><FormControl>
<textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</FormControl><FormMessage /></FormItem>
)} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Zeitplan</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="startDate" render={({ field }) => (
<FormItem><FormLabel>Startdatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="endDate" render={({ field }) => (
<FormItem><FormLabel>Enddatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="registrationDeadline" render={({ field }) => (
<FormItem><FormLabel>Anmeldeschluss</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Kapazität</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="capacity" render={({ field }) => (
<FormItem><FormLabel>Max. Teilnehmer</FormLabel><FormControl>
<Input type="number" min={1} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="minParticipants" render={({ field }) => (
<FormItem><FormLabel>Min. Teilnehmer</FormLabel><FormControl>
<Input type="number" min={0} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="fee" render={({ field }) => (
<FormItem><FormLabel>Gebühr ()</FormLabel><FormControl>
<Input type="number" min={0} step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="reducedFee" render={({ field }) => (
<FormItem><FormLabel>Ermäßigte Gebühr ()</FormLabel><FormControl>
<Input type="number" min={0} step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Status</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="status" render={({ field }) => (
<FormItem><FormLabel>Kursstatus</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="planned">Geplant</option>
<option value="open">Offen</option>
<option value="running">Laufend</option>
<option value="completed">Abgeschlossen</option>
<option value="cancelled">Abgesagt</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
<div className="sm:col-span-1">
<FormField control={form.control} name="notes" render={({ field }) => (
<FormItem><FormLabel>Notizen</FormLabel><FormControl>
<textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</FormControl><FormMessage /></FormItem>
)} />
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Kurs erstellen'}</Button>
</div>
</form>
</Form>
);
}

View File

@@ -1 +1 @@
export {};
export { CreateCourseForm } from './create-course-form';

View File

@@ -0,0 +1,129 @@
'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
CreateCourseSchema,
EnrollParticipantSchema,
CreateSessionSchema,
CreateCategorySchema,
CreateInstructorSchema,
CreateLocationSchema,
} from '../../schema/course.schema';
import { createCourseManagementApi } from '../api';
export const createCourse = authActionClient
.inputSchema(CreateCourseSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.create' }, 'Creating course...');
const result = await api.createCourse(input);
logger.info({ name: 'course.create' }, 'Course created');
return { success: true, data: result };
});
export const enrollParticipant = authActionClient
.inputSchema(EnrollParticipantSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.enrollParticipant' }, 'Enrolling participant...');
const result = await api.enrollParticipant(input);
logger.info({ name: 'course.enrollParticipant' }, 'Participant enrolled');
return { success: true, data: result };
});
export const cancelEnrollment = authActionClient
.inputSchema(
z.object({
participantId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.cancelEnrollment' }, 'Cancelling enrollment...');
const result = await api.cancelEnrollment(input.participantId);
logger.info({ name: 'course.cancelEnrollment' }, 'Enrollment cancelled');
return { success: true, data: result };
});
export const markAttendance = authActionClient
.inputSchema(
z.object({
sessionId: z.string().uuid(),
participantId: z.string().uuid(),
present: z.boolean(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.markAttendance' }, 'Marking attendance...');
const result = await api.markAttendance(input.sessionId, input.participantId, input.present);
logger.info({ name: 'course.markAttendance' }, 'Attendance marked');
return { success: true, data: result };
});
export const createCategory = authActionClient
.inputSchema(CreateCategorySchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.createCategory' }, 'Creating category...');
const result = await api.createCategory(input);
logger.info({ name: 'course.createCategory' }, 'Category created');
return { success: true, data: result };
});
export const createInstructor = authActionClient
.inputSchema(CreateInstructorSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.createInstructor' }, 'Creating instructor...');
const result = await api.createInstructor(input);
logger.info({ name: 'course.createInstructor' }, 'Instructor created');
return { success: true, data: result };
});
export const createLocation = authActionClient
.inputSchema(CreateLocationSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.createLocation' }, 'Creating location...');
const result = await api.createLocation(input);
logger.info({ name: 'course.createLocation' }, 'Location created');
return { success: true, data: result };
});
export const createSession = authActionClient
.inputSchema(CreateSessionSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.createSession' }, 'Creating session...');
const result = await api.createSession(input);
logger.info({ name: 'course.createSession' }, 'Session created');
return { success: true, data: result };
});

View File

@@ -141,5 +141,34 @@ export function createCourseManagementApi(client: SupabaseClient<Database>) {
}
return stats;
},
// --- Create methods for CRUD ---
async createCategory(input: { accountId: string; name: string; description?: string; parentId?: string }) {
const { data, error } = await client.from('course_categories').insert({
account_id: input.accountId, name: input.name, description: input.description,
parent_id: input.parentId,
}).select().single();
if (error) throw error;
return data;
},
async createInstructor(input: { accountId: string; firstName: string; lastName: string; email?: string; phone?: string; qualifications?: string; hourlyRate?: number }) {
const { data, error } = await client.from('course_instructors').insert({
account_id: input.accountId, first_name: input.firstName, last_name: input.lastName,
email: input.email, phone: input.phone, qualifications: input.qualifications,
hourly_rate: input.hourlyRate,
}).select().single();
if (error) throw error;
return data;
},
async createLocation(input: { accountId: string; name: string; address?: string; room?: string; capacity?: number }) {
const { data, error } = await client.from('course_locations').insert({
account_id: input.accountId, name: input.name, address: input.address,
room: input.room, capacity: input.capacity,
}).select().single();
if (error) throw error;
return data;
},
};
}

View File

@@ -12,13 +12,15 @@
"exports": {
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
"./components": "./src/components/index.ts"
"./components": "./src/components/index.ts",
"./actions/*": "./src/server/actions/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@hookform/resolvers": "catalog:",
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
@@ -29,6 +31,7 @@
"next": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
"react-hook-form": "catalog:",
"zod": "catalog:"
}
}

View File

@@ -0,0 +1,158 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { CreateEventSchema } from '../schema/event.schema';
import { createEvent } from '../server/actions/event-actions';
interface Props {
accountId: string;
account: string;
}
export function CreateEventForm({ accountId, account }: Props) {
const router = useRouter();
const form = useForm({
resolver: zodResolver(CreateEventSchema),
defaultValues: {
accountId,
name: '',
description: '',
eventDate: '',
eventTime: '',
endDate: '',
location: '',
capacity: undefined as number | undefined,
minAge: undefined as number | undefined,
maxAge: undefined as number | undefined,
fee: 0,
status: 'planned' as const,
registrationDeadline: '',
contactName: '',
contactEmail: '',
contactPhone: '',
},
});
const { execute, isPending } = useAction(createEvent, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Veranstaltung erfolgreich erstellt');
router.push(`/home/${account}/events-cms`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Erstellen der Veranstaltung');
},
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
<Card>
<CardHeader><CardTitle>Grunddaten</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<FormField control={form.control} name="name" render={({ field }) => (
<FormItem><FormLabel>Veranstaltungsname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</div>
<div className="sm:col-span-2">
<FormField control={form.control} name="description" render={({ field }) => (
<FormItem><FormLabel>Beschreibung</FormLabel><FormControl>
<textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</FormControl><FormMessage /></FormItem>
)} />
</div>
<FormField control={form.control} name="status" render={({ field }) => (
<FormItem><FormLabel>Status</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="planned">Geplant</option>
<option value="open">Offen</option>
<option value="full">Ausgebucht</option>
<option value="running">Laufend</option>
<option value="completed">Abgeschlossen</option>
<option value="cancelled">Abgesagt</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Datum & Ort</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="eventDate" render={({ field }) => (
<FormItem><FormLabel>Veranstaltungsdatum *</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="eventTime" render={({ field }) => (
<FormItem><FormLabel>Uhrzeit</FormLabel><FormControl><Input type="time" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="endDate" render={({ field }) => (
<FormItem><FormLabel>Enddatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="location" render={({ field }) => (
<FormItem><FormLabel>Veranstaltungsort</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="registrationDeadline" render={({ field }) => (
<FormItem><FormLabel>Anmeldeschluss</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Teilnehmer & Kosten</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="capacity" render={({ field }) => (
<FormItem><FormLabel>Max. Teilnehmer</FormLabel><FormControl>
<Input type="number" min={1} {...field} value={field.value ?? ''} onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="fee" render={({ field }) => (
<FormItem><FormLabel>Gebühr ()</FormLabel><FormControl>
<Input type="number" min={0} step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="minAge" render={({ field }) => (
<FormItem><FormLabel>Mindestalter</FormLabel><FormControl>
<Input type="number" min={0} {...field} value={field.value ?? ''} onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="maxAge" render={({ field }) => (
<FormItem><FormLabel>Höchstalter</FormLabel><FormControl>
<Input type="number" min={0} {...field} value={field.value ?? ''} onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)} />
</FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Kontakt</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="contactName" render={({ field }) => (
<FormItem><FormLabel>Ansprechpartner</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="contactEmail" render={({ field }) => (
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="contactPhone" render={({ field }) => (
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input type="tel" {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Veranstaltung erstellen'}</Button>
</div>
</form>
</Form>
);
}

View File

@@ -1 +1 @@
export {};
export { CreateEventForm } from './create-event-form';

View File

@@ -0,0 +1,51 @@
'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
CreateEventSchema,
EventRegistrationSchema,
CreateHolidayPassSchema,
} from '../../schema/event.schema';
import { createEventManagementApi } from '../api';
export const createEvent = authActionClient
.inputSchema(CreateEventSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createEventManagementApi(client);
logger.info({ name: 'event.create' }, 'Creating event...');
const result = await api.createEvent(input);
logger.info({ name: 'event.create' }, 'Event created');
return { success: true, data: result };
});
export const registerForEvent = authActionClient
.inputSchema(EventRegistrationSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createEventManagementApi(client);
logger.info({ name: 'event.register' }, 'Registering for event...');
const result = await api.registerForEvent(input);
logger.info({ name: 'event.register' }, 'Registered for event');
return { success: true, data: result };
});
export const createHolidayPass = authActionClient
.inputSchema(CreateHolidayPassSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createEventManagementApi(client);
logger.info({ name: 'event.createHolidayPass' }, 'Creating holiday pass...');
const result = await api.createHolidayPass(input);
logger.info({ name: 'event.createHolidayPass' }, 'Holiday pass created');
return { success: true, data: result };
});

View File

@@ -79,5 +79,15 @@ export function createEventManagementApi(client: SupabaseClient<Database>) {
if (error) throw error;
return data ?? [];
},
async createHolidayPass(input: { accountId: string; name: string; year: number; description?: string; price?: number; validFrom?: string; validUntil?: string }) {
const { data, error } = await client.from('holiday_passes').insert({
account_id: input.accountId, name: input.name, year: input.year,
description: input.description, price: input.price ?? 0,
valid_from: input.validFrom, valid_until: input.validUntil,
}).select().single();
if (error) throw error;
return data;
},
};
}

View File

@@ -12,13 +12,15 @@
"exports": {
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
"./components": "./src/components/index.ts"
"./components": "./src/components/index.ts",
"./actions/*": "./src/server/actions/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@hookform/resolvers": "catalog:",
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
@@ -29,6 +31,7 @@
"next": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
"react-hook-form": "catalog:",
"zod": "catalog:"
}
}

View File

@@ -0,0 +1,190 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, useFieldArray } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { useMemo } from 'react';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { CreateInvoiceSchema } from '../schema/finance.schema';
import { createInvoice } from '../server/actions/finance-actions';
interface Props {
accountId: string;
account: string;
}
export function CreateInvoiceForm({ accountId, account }: Props) {
const router = useRouter();
const form = useForm({
resolver: zodResolver(CreateInvoiceSchema),
defaultValues: {
accountId,
invoiceNumber: '',
recipientName: '',
recipientAddress: '',
issueDate: new Date().toISOString().split('T')[0]!,
dueDate: '',
taxRate: 19,
notes: '',
items: [{ description: '', quantity: 1, unitPrice: 0 }],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'items',
});
const watchedItems = form.watch('items');
const watchedTaxRate = form.watch('taxRate');
const { subtotal, taxAmount, total } = useMemo(() => {
const sub = (watchedItems ?? []).reduce((sum, item) => {
return sum + (item.quantity || 0) * (item.unitPrice || 0);
}, 0);
const tax = sub * ((watchedTaxRate || 0) / 100);
return { subtotal: sub, taxAmount: tax, total: sub + tax };
}, [watchedItems, watchedTaxRate]);
const { execute, isPending } = useAction(createInvoice, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Rechnung erfolgreich erstellt');
router.push(`/home/${account}/finance-cms`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Erstellen der Rechnung');
},
});
const formatCurrency = (value: number) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
<Card>
<CardHeader><CardTitle>Rechnungsdaten</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="invoiceNumber" render={({ field }) => (
<FormItem><FormLabel>Rechnungsnummer *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="issueDate" render={({ field }) => (
<FormItem><FormLabel>Rechnungsdatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="dueDate" render={({ field }) => (
<FormItem><FormLabel>Fälligkeitsdatum *</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Empfänger</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="recipientName" render={({ field }) => (
<FormItem><FormLabel>Name *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="recipientAddress" render={({ field }) => (
<FormItem><FormLabel>Adresse</FormLabel><FormControl>
<textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Positionen</CardTitle>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ description: '', quantity: 1, unitPrice: 0 })}
>
+ Position hinzufügen
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{fields.map((item, index) => (
<div key={item.id} className="grid grid-cols-1 gap-4 rounded-lg border p-4 sm:grid-cols-12">
<div className="sm:col-span-6">
<FormField control={form.control} name={`items.${index}.description`} render={({ field }) => (
<FormItem><FormLabel>Beschreibung *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</div>
<div className="sm:col-span-2">
<FormField control={form.control} name={`items.${index}.quantity`} render={({ field }) => (
<FormItem><FormLabel>Menge</FormLabel><FormControl>
<Input type="number" min={0.01} step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
</div>
<div className="sm:col-span-3">
<FormField control={form.control} name={`items.${index}.unitPrice`} render={({ field }) => (
<FormItem><FormLabel>Einzelpreis ()</FormLabel><FormControl>
<Input type="number" step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
</div>
<div className="flex items-end sm:col-span-1">
{fields.length > 1 && (
<Button type="button" variant="ghost" size="sm" onClick={() => remove(index)} className="text-destructive">
</Button>
)}
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Beträge</CardTitle></CardHeader>
<CardContent className="space-y-3">
<FormField control={form.control} name="taxRate" render={({ field }) => (
<FormItem className="grid grid-cols-2 items-center gap-4">
<FormLabel>MwSt.-Satz (%)</FormLabel>
<FormControl>
<Input type="number" min={0} step="0.5" className="max-w-[120px]" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl>
<FormMessage />
</FormItem>
)} />
<div className="space-y-1 rounded-lg bg-muted p-4 text-sm">
<div className="flex justify-between">
<span>Zwischensumme (netto)</span>
<span className="font-medium">{formatCurrency(subtotal)}</span>
</div>
<div className="flex justify-between">
<span>MwSt. ({watchedTaxRate}%)</span>
<span className="font-medium">{formatCurrency(taxAmount)}</span>
</div>
<div className="flex justify-between border-t pt-1 text-base font-semibold">
<span>Gesamtbetrag</span>
<span>{formatCurrency(total)}</span>
</div>
</div>
<FormField control={form.control} name="notes" render={({ field }) => (
<FormItem><FormLabel>Anmerkungen</FormLabel><FormControl>
<textarea {...field} className="flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Rechnung erstellen'}</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { z } from 'zod';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { createSepaBatch } from '../server/actions/finance-actions';
const FormSchema = z.object({
accountId: z.string().uuid(),
batchType: z.enum(['direct_debit', 'credit_transfer']),
description: z.string().optional(),
executionDate: z.string().min(1, 'Ausführungsdatum ist erforderlich'),
painFormat: z.string().default('pain.008.003.02'),
});
interface Props {
accountId: string;
account: string;
}
export function CreateSepaBatchForm({ accountId, account }: Props) {
const router = useRouter();
const form = useForm({
resolver: zodResolver(FormSchema),
defaultValues: {
accountId,
batchType: 'direct_debit' as const,
description: '',
executionDate: new Date(Date.now() + 7 * 86400000).toISOString().split('T')[0]!,
painFormat: 'pain.008.003.02',
},
});
const { execute, isPending } = useAction(createSepaBatch, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('SEPA-Einzug erstellt');
router.push(`/home/${account}/finance/sepa`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Erstellen');
},
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6 max-w-2xl">
<Card>
<CardHeader>
<CardTitle>SEPA-Einzug erstellen</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField control={form.control} name="batchType" render={({ field }) => (
<FormItem>
<FormLabel>Typ</FormLabel>
<FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="direct_debit">Lastschrift (SEPA Core)</option>
<option value="credit_transfer">Überweisung</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="description" render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung</FormLabel>
<FormControl><Input placeholder="z.B. Mitgliedsbeiträge Q1 2026" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="executionDate" render={({ field }) => (
<FormItem>
<FormLabel>Ausführungsdatum *</FormLabel>
<FormControl><Input type="date" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird erstellt...' : 'Einzug erstellen'}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -1 +1,2 @@
export {};
export { CreateInvoiceForm } from './create-invoice-form';
export { CreateSepaBatchForm } from './create-sepa-batch-form';

View File

@@ -0,0 +1,93 @@
'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
CreateSepaBatchSchema,
AddSepaItemSchema,
CreateInvoiceSchema,
} from '../../schema/finance.schema';
import { createFinanceApi } from '../api';
export const createSepaBatch = authActionClient
.inputSchema(CreateSepaBatchSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFinanceApi(client);
const userId = ctx.user.id;
logger.info({ name: 'finance.createSepaBatch' }, 'Creating SEPA batch...');
const result = await api.createBatch(input, userId);
logger.info({ name: 'finance.createSepaBatch' }, 'SEPA batch created');
return { success: true, data: result };
});
export const addSepaItem = authActionClient
.inputSchema(AddSepaItemSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFinanceApi(client);
logger.info({ name: 'finance.addSepaItem' }, 'Adding SEPA item...');
const result = await api.addItem(input);
logger.info({ name: 'finance.addSepaItem' }, 'SEPA item added');
return { success: true, data: result };
});
export const generateSepaXml = authActionClient
.inputSchema(
z.object({
batchId: z.string().uuid(),
accountId: z.string().uuid(),
creditorName: z.string(),
creditorIban: z.string(),
creditorBic: z.string(),
creditorId: z.string(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFinanceApi(client);
logger.info({ name: 'finance.generateSepaXml' }, 'Generating SEPA XML...');
const result = await api.generateSepaXml(input.batchId, {
name: input.creditorName,
iban: input.creditorIban,
bic: input.creditorBic,
creditorId: input.creditorId,
});
logger.info({ name: 'finance.generateSepaXml' }, 'SEPA XML generated');
return { success: true, xml: result };
});
export const createInvoice = authActionClient
.inputSchema(CreateInvoiceSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFinanceApi(client);
const userId = ctx.user.id;
logger.info({ name: 'finance.createInvoice' }, 'Creating invoice...');
const result = await api.createInvoice(input, userId);
logger.info({ name: 'finance.createInvoice' }, 'Invoice created');
return { success: true, data: result };
});
// Gap 3: SEPA auto-populate from members
export const populateBatchFromMembers = authActionClient
.inputSchema(z.object({ batchId: z.string().uuid(), accountId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFinanceApi(client);
logger.info({ name: 'sepa.populate' }, 'Populating batch from members...');
const result = await api.populateBatchFromMembers(input.batchId, input.accountId);
logger.info({ name: 'sepa.populate', count: result.addedCount }, 'Populated');
return { success: true, addedCount: result.addedCount };
});

View File

@@ -148,6 +148,59 @@ export function createFinanceApi(client: SupabaseClient<Database>) {
return { ...data, items: items ?? [] };
},
// --- SEPA auto-populate from members (Gap 3) ---
async populateBatchFromMembers(batchId: string, accountId: string) {
// Get all active members with active SEPA mandates + dues categories
const { data: members, error: memberError } = await client
.from('members')
.select('id, first_name, last_name, dues_category_id')
.eq('account_id', accountId)
.eq('status', 'active');
if (memberError) throw memberError;
const { data: mandates, error: mandateError } = await client
.from('sepa_mandates')
.select('*')
.eq('account_id', accountId)
.eq('status', 'active')
.eq('is_primary', true);
if (mandateError) throw mandateError;
const { data: categories, error: catError } = await client
.from('dues_categories')
.select('id, amount')
.eq('account_id', accountId);
if (catError) throw catError;
const mandateMap = new Map((mandates ?? []).map((m: any) => [m.member_id, m]));
const categoryMap = new Map((categories ?? []).map((c: any) => [c.id, Number(c.amount)]));
let addedCount = 0;
for (const member of (members ?? []) as any[]) {
const mandate = mandateMap.get(member.id);
if (!mandate) continue;
const amount = member.dues_category_id ? categoryMap.get(member.dues_category_id) ?? 0 : 0;
if (amount <= 0) continue;
const { error } = await client.from('sepa_items').insert({
batch_id: batchId,
member_id: member.id,
debtor_name: `${member.first_name} ${member.last_name}`,
debtor_iban: mandate.iban,
debtor_bic: mandate.bic,
amount,
mandate_id: mandate.mandate_reference,
mandate_date: mandate.mandate_date,
remittance_info: `Mitgliedsbeitrag ${new Date().getFullYear()}`,
});
if (!error) addedCount++;
}
await this.recalculateBatchTotals(batchId);
return { addedCount };
},
// --- Utilities ---
validateIban,
};

View File

@@ -12,23 +12,30 @@
"exports": {
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
"./components": "./src/components/index.ts"
"./components": "./src/components/index.ts",
"./actions/*": "./src/server/actions/*.ts",
"./lib/*": "./src/lib/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@hookform/resolvers": "catalog:",
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "catalog:",
"@types/papaparse": "catalog:",
"@types/react": "catalog:",
"lucide-react": "catalog:",
"next": "catalog:",
"next-safe-action": "catalog:",
"papaparse": "catalog:",
"react": "catalog:",
"react-hook-form": "catalog:",
"zod": "catalog:"
}
}

View File

@@ -0,0 +1,200 @@
'use client';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { toast } from '@kit/ui/sonner';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { approveApplication, rejectApplication } from '../server/actions/member-actions';
interface ApplicationWorkflowProps {
applications: Array<Record<string, unknown>>;
accountId: string;
account: string;
}
const APPLICATION_STATUS_LABELS: Record<string, string> = {
submitted: 'Eingereicht',
review: 'In Prüfung',
approved: 'Genehmigt',
rejected: 'Abgelehnt',
};
function getApplicationStatusColor(
status: string,
): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case 'approved':
return 'default';
case 'submitted':
case 'review':
return 'outline';
case 'rejected':
return 'destructive';
default:
return 'secondary';
}
}
export function ApplicationWorkflow({
applications,
accountId,
account,
}: ApplicationWorkflowProps) {
const router = useRouter();
const form = useForm();
const { execute: executeApprove, isPending: isApproving } = useAction(
approveApplication,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Antrag genehmigt Mitglied wurde erstellt');
router.refresh();
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Genehmigen');
},
},
);
const { execute: executeReject, isPending: isRejecting } = useAction(
rejectApplication,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Antrag wurde abgelehnt');
router.refresh();
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Ablehnen');
},
},
);
const handleApprove = useCallback(
(applicationId: string) => {
if (
!window.confirm(
'Mitglied wird automatisch erstellt. Fortfahren?',
)
) {
return;
}
executeApprove({ applicationId, accountId });
},
[executeApprove, accountId],
);
const handleReject = useCallback(
(applicationId: string) => {
const reason = window.prompt(
'Bitte geben Sie einen Ablehnungsgrund ein:',
);
if (reason === null) return; // cancelled
executeReject({
applicationId,
accountId,
reviewNotes: reason,
});
},
[executeReject, accountId],
);
const isPending = isApproving || isRejecting;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Aufnahmeanträge</h2>
<p className="text-sm text-muted-foreground">
{applications.length} Antrag{applications.length !== 1 ? 'e' : ''}
</p>
</div>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">Name</th>
<th className="px-4 py-3 text-left font-medium">E-Mail</th>
<th className="px-4 py-3 text-left font-medium">Datum</th>
<th className="px-4 py-3 text-left font-medium">Status</th>
<th className="px-4 py-3 text-right font-medium">Aktionen</th>
</tr>
</thead>
<tbody>
{applications.length === 0 ? (
<tr>
<td
colSpan={5}
className="px-4 py-8 text-center text-muted-foreground"
>
Keine Aufnahmeanträge vorhanden.
</td>
</tr>
) : (
applications.map((app) => {
const appId = String(app.id ?? '');
const appStatus = String(app.status ?? 'submitted');
const isActionable =
appStatus === 'submitted' || appStatus === 'review';
return (
<tr key={appId} className="border-b">
<td className="px-4 py-3">
{String(app.last_name ?? '')},{' '}
{String(app.first_name ?? '')}
</td>
<td className="px-4 py-3 text-muted-foreground">
{String(app.email ?? '—')}
</td>
<td className="px-4 py-3 text-muted-foreground">
{app.created_at
? new Date(String(app.created_at)).toLocaleDateString(
'de-DE',
)
: '—'}
</td>
<td className="px-4 py-3">
<Badge variant={getApplicationStatusColor(appStatus)}>
{APPLICATION_STATUS_LABELS[appStatus] ?? appStatus}
</Badge>
</td>
<td className="px-4 py-3 text-right">
{isActionable && (
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="default"
disabled={isPending}
onClick={() => handleApprove(appId)}
>
Genehmigen
</Button>
<Button
size="sm"
variant="destructive"
disabled={isPending}
onClick={() => handleReject(appId)}
>
Ablehnen
</Button>
</div>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,243 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { CreateMemberSchema } from '../schema/member.schema';
import { createMember } from '../server/actions/member-actions';
interface Props {
accountId: string;
account: string; // slug for redirect
duesCategories: Array<{ id: string; name: string; amount: number }>;
}
export function CreateMemberForm({ accountId, account, duesCategories }: Props) {
const router = useRouter();
const form = useForm({
resolver: zodResolver(CreateMemberSchema),
defaultValues: {
accountId,
firstName: '', lastName: '', email: '', phone: '', mobile: '',
street: '', houseNumber: '', postalCode: '', city: '', country: 'DE',
memberNumber: '', status: 'active' as const, entryDate: new Date().toISOString().split('T')[0]!,
iban: '', bic: '', accountHolder: '', gdprConsent: false, notes: '',
},
});
const { execute, isPending } = useAction(createMember, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Mitglied erfolgreich erstellt');
router.push(`/home/${account}/members-cms`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Erstellen');
},
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
<Card>
<CardHeader><CardTitle>Persönliche Daten</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="firstName" render={({ field }) => (
<FormItem><FormLabel>Vorname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="lastName" render={({ field }) => (
<FormItem><FormLabel>Nachname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="dateOfBirth" render={({ field }) => (
<FormItem><FormLabel>Geburtsdatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="gender" render={({ field }) => (
<FormItem><FormLabel>Geschlecht</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value=""> Bitte wählen </option>
<option value="male">Männlich</option>
<option value="female">Weiblich</option>
<option value="diverse">Divers</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Kontakt</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="phone" render={({ field }) => (
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input type="tel" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="mobile" render={({ field }) => (
<FormItem><FormLabel>Mobil</FormLabel><FormControl><Input type="tel" {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Adresse</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="street" render={({ field }) => (
<FormItem><FormLabel>Straße</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="houseNumber" render={({ field }) => (
<FormItem><FormLabel>Hausnummer</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="postalCode" render={({ field }) => (
<FormItem><FormLabel>PLZ</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="city" render={({ field }) => (
<FormItem><FormLabel>Ort</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Mitgliedschaft</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="memberNumber" render={({ field }) => (
<FormItem><FormLabel>Mitgliedsnr.</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="status" render={({ field }) => (
<FormItem><FormLabel>Status</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
<option value="pending">Ausstehend</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="entryDate" render={({ field }) => (
<FormItem><FormLabel>Eintrittsdatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
{duesCategories.length > 0 && (
<FormField control={form.control} name="duesCategoryId" render={({ field }) => (
<FormItem><FormLabel>Beitragskategorie</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value=""> Keine </option>
{duesCategories.map(c => <option key={c.id} value={c.id}>{c.name} ({c.amount} )</option>)}
</select>
</FormControl><FormMessage /></FormItem>
)} />
)}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>SEPA-Bankdaten</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="iban" render={({ field }) => (
<FormItem><FormLabel>IBAN</FormLabel><FormControl><Input placeholder="DE89 3704 0044 0532 0130 00" {...field} onChange={(e) => field.onChange(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="bic" render={({ field }) => (
<FormItem><FormLabel>BIC</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="accountHolder" render={({ field }) => (
<FormItem><FormLabel>Kontoinhaber</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
{/* Guardian (Gap 4) */}
<Card>
<CardHeader><CardTitle>Erziehungsberechtigte (Jugend)</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="guardianName" render={({ field }) => (
<FormItem><FormLabel>Name Erziehungsberechtigte/r</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="guardianPhone" render={({ field }) => (
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input type="tel" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="guardianEmail" render={({ field }) => (
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
{/* Lifecycle flags (Gap 4) */}
<Card>
<CardHeader><CardTitle>Mitgliedschaftsmerkmale</CardTitle></CardHeader>
<CardContent className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{([
['isHonorary', 'Ehrenmitglied'],
['isFoundingMember', 'Gründungsmitglied'],
['isYouth', 'Jugendmitglied'],
['isRetiree', 'Rentner/Senior'],
['isProbationary', 'Probejahr'],
] as const).map(([name, label]) => (
<FormField key={name} control={form.control} name={name} render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl>
<input type="checkbox" checked={field.value as boolean} onChange={field.onChange} className="h-4 w-4 rounded border-input" />
</FormControl>
<FormLabel className="!mt-0">{label}</FormLabel>
</FormItem>
)} />
))}
</CardContent>
</Card>
{/* GDPR granular (Gap 4) */}
<Card>
<CardHeader><CardTitle>Datenschutz-Einwilligungen</CardTitle></CardHeader>
<CardContent className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{([
['gdprConsent', 'Allgemeine Einwilligung'],
['gdprNewsletter', 'Newsletter'],
['gdprInternet', 'Internet/Homepage'],
['gdprPrint', 'Vereinszeitung'],
['gdprBirthdayInfo', 'Geburtstagsinfo'],
] as const).map(([name, label]) => (
<FormField key={name} control={form.control} name={name} render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl>
<input type="checkbox" checked={field.value as boolean} onChange={field.onChange} className="h-4 w-4 rounded border-input" />
</FormControl>
<FormLabel className="!mt-0">{label}</FormLabel>
</FormItem>
)} />
))}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Sonstiges</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="salutation" render={({ field }) => (
<FormItem><FormLabel>Anrede</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value=""> Keine </option>
<option value="Herr">Herr</option>
<option value="Frau">Frau</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="birthplace" render={({ field }) => (
<FormItem><FormLabel>Geburtsort</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="street2" render={({ field }) => (
<FormItem><FormLabel>Adresszusatz</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</div>
<FormField control={form.control} name="notes" render={({ field }) => (
<FormItem><FormLabel>Notizen</FormLabel><FormControl><textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Mitglied erstellen'}</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,258 @@
'use client';
import { useState, useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { toast } from '@kit/ui/sonner';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
createDuesCategory,
deleteDuesCategory,
} from '../server/actions/member-actions';
interface DuesCategoryManagerProps {
categories: Array<Record<string, unknown>>;
accountId: string;
}
const INTERVAL_LABELS: Record<string, string> = {
monthly: 'Monatlich',
quarterly: 'Vierteljährlich',
semiannual: 'Halbjährlich',
annual: 'Jährlich',
};
interface CategoryFormValues {
name: string;
description: string;
amount: number;
interval: string;
isDefault: boolean;
}
export function DuesCategoryManager({
categories,
accountId,
}: DuesCategoryManagerProps) {
const router = useRouter();
const [showForm, setShowForm] = useState(false);
const form = useForm<CategoryFormValues>({
defaultValues: {
name: '',
description: '',
amount: 0,
interval: 'annual',
isDefault: false,
},
});
const { execute: executeCreate, isPending: isCreating } = useAction(
createDuesCategory,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Beitragskategorie erstellt');
form.reset();
setShowForm(false);
router.refresh();
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Erstellen');
},
},
);
const { execute: executeDelete, isPending: isDeletePending } = useAction(
deleteDuesCategory,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Beitragskategorie gelöscht');
router.refresh();
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Löschen');
},
},
);
const handleSubmit = useCallback(
(values: CategoryFormValues) => {
executeCreate({
accountId,
name: values.name,
description: values.description,
amount: Number(values.amount),
interval: values.interval as 'monthly' | 'quarterly' | 'half_yearly' | 'yearly',
isDefault: values.isDefault,
});
},
[executeCreate, accountId],
);
const handleDelete = useCallback(
(categoryId: string, categoryName: string) => {
if (
!window.confirm(
`Beitragskategorie "${categoryName}" wirklich löschen?`,
)
) {
return;
}
executeDelete({ categoryId });
},
[executeDelete],
);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Beitragskategorien</h2>
<Button
size="sm"
variant={showForm ? 'outline' : 'default'}
onClick={() => setShowForm(!showForm)}
>
{showForm ? 'Abbrechen' : 'Neue Kategorie'}
</Button>
</div>
{/* Inline Create Form */}
{showForm && (
<Card>
<CardHeader>
<CardTitle>Neue Beitragskategorie</CardTitle>
</CardHeader>
<CardContent>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5"
>
<div className="space-y-1">
<label className="text-sm font-medium">Name *</label>
<Input
placeholder="z.B. Standardbeitrag"
{...form.register('name', { required: true })}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Betrag () *</label>
<Input
type="number"
step="0.01"
min="0"
placeholder="0.00"
{...form.register('amount', {
required: true,
valueAsNumber: true,
})}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Intervall</label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
{...form.register('interval')}
>
<option value="monthly">Monatlich</option>
<option value="quarterly">Vierteljährlich</option>
<option value="semiannual">Halbjährlich</option>
<option value="annual">Jährlich</option>
</select>
</div>
<div className="flex items-end space-x-2">
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
className="rounded border-input"
{...form.register('isDefault')}
/>
Standard
</label>
</div>
<div className="flex items-end">
<Button type="submit" disabled={isCreating} className="w-full">
{isCreating ? 'Erstelle...' : 'Erstellen'}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* Table */}
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">Name</th>
<th className="px-4 py-3 text-left font-medium">Beschreibung</th>
<th className="px-4 py-3 text-right font-medium">Betrag</th>
<th className="px-4 py-3 text-left font-medium">Intervall</th>
<th className="px-4 py-3 text-center font-medium">Standard</th>
<th className="px-4 py-3 text-right font-medium">Aktionen</th>
</tr>
</thead>
<tbody>
{categories.length === 0 ? (
<tr>
<td
colSpan={6}
className="px-4 py-8 text-center text-muted-foreground"
>
Keine Beitragskategorien vorhanden.
</td>
</tr>
) : (
categories.map((cat) => {
const catId = String(cat.id ?? '');
const catName = String(cat.name ?? '');
const interval = String(cat.interval ?? '');
const amount = Number(cat.amount ?? 0);
const isDefault = Boolean(cat.is_default);
return (
<tr key={catId} className="border-b">
<td className="px-4 py-3 font-medium">{catName}</td>
<td className="px-4 py-3 text-muted-foreground">
{String(cat.description ?? '—')}
</td>
<td className="px-4 py-3 text-right font-mono">
{amount.toLocaleString('de-DE', {
style: 'currency',
currency: 'EUR',
})}
</td>
<td className="px-4 py-3">
{INTERVAL_LABELS[interval] ?? interval}
</td>
<td className="px-4 py-3 text-center">
{isDefault ? '✓' : '✗'}
</td>
<td className="px-4 py-3 text-right">
<Button
size="sm"
variant="destructive"
disabled={isDeletePending}
onClick={() => handleDelete(catId, catName)}
>
Löschen
</Button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,228 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { UpdateMemberSchema } from '../schema/member.schema';
import { updateMember } from '../server/actions/member-actions';
interface Props {
member: Record<string, unknown>;
account: string;
accountId: string;
}
export function EditMemberForm({ member, account, accountId }: Props) {
const router = useRouter();
const memberId = String(member.id);
const form = useForm({
resolver: zodResolver(UpdateMemberSchema),
defaultValues: {
memberId,
accountId,
firstName: String(member.first_name ?? ''),
lastName: String(member.last_name ?? ''),
email: String(member.email ?? ''),
phone: String(member.phone ?? ''),
mobile: String(member.mobile ?? ''),
street: String(member.street ?? ''),
houseNumber: String(member.house_number ?? ''),
postalCode: String(member.postal_code ?? ''),
city: String(member.city ?? ''),
status: String(member.status ?? 'active') as 'active',
memberNumber: String(member.member_number ?? ''),
salutation: String(member.salutation ?? ''),
birthplace: String(member.birthplace ?? ''),
street2: String(member.street2 ?? ''),
guardianName: String(member.guardian_name ?? ''),
guardianPhone: String(member.guardian_phone ?? ''),
guardianEmail: String(member.guardian_email ?? ''),
iban: String(member.iban ?? ''),
bic: String(member.bic ?? ''),
accountHolder: String(member.account_holder ?? ''),
notes: String(member.notes ?? ''),
isHonorary: Boolean(member.is_honorary),
isFoundingMember: Boolean(member.is_founding_member),
isYouth: Boolean(member.is_youth),
isRetiree: Boolean(member.is_retiree),
isProbationary: Boolean(member.is_probationary),
gdprConsent: Boolean(member.gdpr_consent),
gdprNewsletter: Boolean(member.gdpr_newsletter),
gdprInternet: Boolean(member.gdpr_internet),
gdprPrint: Boolean(member.gdpr_print),
gdprBirthdayInfo: Boolean(member.gdpr_birthday_info),
},
});
const { execute, isPending } = useAction(updateMember, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Mitglied aktualisiert');
router.push(`/home/${account}/members-cms/${memberId}`);
router.refresh();
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Aktualisieren');
},
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
<Card>
<CardHeader><CardTitle>Persönliche Daten</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="salutation" render={({ field }) => (
<FormItem><FormLabel>Anrede</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value=""></option><option value="Herr">Herr</option><option value="Frau">Frau</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
<div />
<FormField control={form.control} name="firstName" render={({ field }) => (
<FormItem><FormLabel>Vorname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="lastName" render={({ field }) => (
<FormItem><FormLabel>Nachname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="memberNumber" render={({ field }) => (
<FormItem><FormLabel>Mitgliedsnr.</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="birthplace" render={({ field }) => (
<FormItem><FormLabel>Geburtsort</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Kontakt</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="phone" render={({ field }) => (
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="mobile" render={({ field }) => (
<FormItem><FormLabel>Mobil</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Adresse</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="street" render={({ field }) => (
<FormItem><FormLabel>Straße</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="houseNumber" render={({ field }) => (
<FormItem><FormLabel>Hausnummer</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="street2" render={({ field }) => (
<FormItem><FormLabel>Adresszusatz</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<div />
<FormField control={form.control} name="postalCode" render={({ field }) => (
<FormItem><FormLabel>PLZ</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="city" render={({ field }) => (
<FormItem><FormLabel>Ort</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>SEPA-Bankdaten</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="iban" render={({ field }) => (
<FormItem><FormLabel>IBAN</FormLabel><FormControl><Input {...field} onChange={(e) => field.onChange(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="bic" render={({ field }) => (
<FormItem><FormLabel>BIC</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="accountHolder" render={({ field }) => (
<FormItem><FormLabel>Kontoinhaber</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Erziehungsberechtigte</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="guardianName" render={({ field }) => (
<FormItem><FormLabel>Name</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="guardianPhone" render={({ field }) => (
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="guardianEmail" render={({ field }) => (
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Merkmale & Datenschutz</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{([
['isHonorary', 'Ehrenmitglied'], ['isFoundingMember', 'Gründungsmitglied'],
['isYouth', 'Jugend'], ['isRetiree', 'Rentner'],
['isProbationary', 'Probejahr'],
] as const).map(([name, label]) => (
<FormField key={name} control={form.control} name={name} render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl><input type="checkbox" checked={field.value as boolean} onChange={field.onChange} className="h-4 w-4" /></FormControl>
<FormLabel className="!mt-0 text-sm">{label}</FormLabel>
</FormItem>
)} />
))}
</div>
<div className="border-t pt-3">
<p className="text-xs font-medium text-muted-foreground mb-2">DSGVO-Einwilligungen</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{([
['gdprConsent', 'Allgemein'], ['gdprNewsletter', 'Newsletter'],
['gdprInternet', 'Internet'], ['gdprPrint', 'Zeitung'],
['gdprBirthdayInfo', 'Geburtstag'],
] as const).map(([name, label]) => (
<FormField key={name} control={form.control} name={name} render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl><input type="checkbox" checked={field.value as boolean} onChange={field.onChange} className="h-4 w-4" /></FormControl>
<FormLabel className="!mt-0 text-sm">{label}</FormLabel>
</FormItem>
)} />
))}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Notizen</CardTitle></CardHeader>
<CardContent>
<FormField control={form.control} name="notes" render={({ field }) => (
<FormItem><FormControl><textarea {...field} rows={4} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird gespeichert...' : 'Änderungen speichern'}</Button>
</div>
</form>
</Form>
);
}

View File

@@ -1,3 +1,8 @@
export {};
// Phase 4 components: members-table, member-form, member-detail,
// application-workflow, dues-category-manager, member-statistics-dashboard
export { CreateMemberForm } from './create-member-form';
export { EditMemberForm } from './edit-member-form';
export { MembersDataTable } from './members-data-table';
export { MemberDetailView } from './member-detail-view';
export { ApplicationWorkflow } from './application-workflow';
export { DuesCategoryManager } from './dues-category-manager';
export { MandateManager } from './mandate-manager';
export { MemberImportWizard } from './member-import-wizard';

View File

@@ -0,0 +1,309 @@
'use client';
import { useState, useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { toast } from '@kit/ui/sonner';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { formatIban } from '../lib/member-utils';
import { createMandate, revokeMandate } from '../server/actions/member-actions';
interface MandateManagerProps {
mandates: Array<Record<string, unknown>>;
memberId: string;
accountId: string;
}
const SEQUENCE_LABELS: Record<string, string> = {
FRST: 'Erstlastschrift',
RCUR: 'Wiederkehrend',
FNAL: 'Letzte',
OOFF: 'Einmalig',
};
function getMandateStatusColor(
status: string,
): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case 'active':
return 'default';
case 'pending':
return 'outline';
case 'revoked':
case 'expired':
return 'destructive';
default:
return 'secondary';
}
}
const MANDATE_STATUS_LABELS: Record<string, string> = {
active: 'Aktiv',
pending: 'Ausstehend',
revoked: 'Widerrufen',
expired: 'Abgelaufen',
};
interface MandateFormValues {
mandateReference: string;
iban: string;
bic: string;
accountHolder: string;
mandateDate: string;
sequence: string;
}
export function MandateManager({
mandates,
memberId,
accountId,
}: MandateManagerProps) {
const router = useRouter();
const [showForm, setShowForm] = useState(false);
const form = useForm<MandateFormValues>({
defaultValues: {
mandateReference: '',
iban: '',
bic: '',
accountHolder: '',
mandateDate: new Date().toISOString().split('T')[0]!,
sequence: 'FRST',
},
});
const { execute: executeCreate, isPending: isCreating } = useAction(
createMandate,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('SEPA-Mandat erstellt');
form.reset();
setShowForm(false);
router.refresh();
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Erstellen');
},
},
);
const { execute: executeRevoke, isPending: isRevoking } = useAction(
revokeMandate,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Mandat widerrufen');
router.refresh();
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Widerrufen');
},
},
);
const handleSubmit = useCallback(
(values: MandateFormValues) => {
executeCreate({
memberId,
accountId,
mandateReference: values.mandateReference,
iban: values.iban,
bic: values.bic,
accountHolder: values.accountHolder,
mandateDate: values.mandateDate,
sequence: values.sequence as "FRST" | "RCUR" | "FNAL" | "OOFF",
});
},
[executeCreate, memberId, accountId],
);
const handleRevoke = useCallback(
(mandateId: string, reference: string) => {
if (
!window.confirm(
`Mandat "${reference}" wirklich widerrufen?`,
)
) {
return;
}
executeRevoke({ mandateId });
},
[executeRevoke],
);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">SEPA-Mandate</h2>
<Button
size="sm"
variant={showForm ? 'outline' : 'default'}
onClick={() => setShowForm(!showForm)}
>
{showForm ? 'Abbrechen' : 'Neues Mandat'}
</Button>
</div>
{/* Inline Create Form */}
{showForm && (
<Card>
<CardHeader>
<CardTitle>Neues SEPA-Mandat</CardTitle>
</CardHeader>
<CardContent>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
<div className="space-y-1">
<label className="text-sm font-medium">Mandatsreferenz *</label>
<Input
placeholder="MANDATE-001"
{...form.register('mandateReference', { required: true })}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">IBAN *</label>
<Input
placeholder="DE89 3704 0044 0532 0130 00"
{...form.register('iban', { required: true })}
onChange={(e) => {
const value = e.target.value
.toUpperCase()
.replace(/[^A-Z0-9]/g, '');
form.setValue('iban', value);
}}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">BIC</label>
<Input
placeholder="COBADEFFXXX"
{...form.register('bic')}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Kontoinhaber *</label>
<Input
placeholder="Max Mustermann"
{...form.register('accountHolder', { required: true })}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Mandatsdatum *</label>
<Input
type="date"
{...form.register('mandateDate', { required: true })}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Sequenz</label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
{...form.register('sequence')}
>
<option value="FRST">FRST Erstlastschrift</option>
<option value="RCUR">RCUR Wiederkehrend</option>
<option value="FNAL">FNAL Letzte</option>
<option value="OOFF">OOFF Einmalig</option>
</select>
</div>
<div className="sm:col-span-2 lg:col-span-3">
<Button type="submit" disabled={isCreating}>
{isCreating ? 'Erstelle...' : 'Mandat erstellen'}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* Table */}
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">Referenz</th>
<th className="px-4 py-3 text-left font-medium">IBAN</th>
<th className="px-4 py-3 text-left font-medium">Kontoinhaber</th>
<th className="px-4 py-3 text-left font-medium">Datum</th>
<th className="px-4 py-3 text-left font-medium">Status</th>
<th className="px-4 py-3 text-center font-medium">Primär</th>
<th className="px-4 py-3 text-right font-medium">Aktionen</th>
</tr>
</thead>
<tbody>
{mandates.length === 0 ? (
<tr>
<td
colSpan={7}
className="px-4 py-8 text-center text-muted-foreground"
>
Keine SEPA-Mandate vorhanden.
</td>
</tr>
) : (
mandates.map((mandate) => {
const mandateId = String(mandate.id ?? '');
const reference = String(mandate.mandate_reference ?? '—');
const mandateStatus = String(mandate.status ?? 'pending');
const isPrimary = Boolean(mandate.is_primary);
const canRevoke =
mandateStatus === 'active' || mandateStatus === 'pending';
return (
<tr key={mandateId} className="border-b">
<td className="px-4 py-3 font-mono text-xs">
{reference}
</td>
<td className="px-4 py-3 font-mono text-xs">
{formatIban(mandate.iban as string | null | undefined)}
</td>
<td className="px-4 py-3">
{String(mandate.account_holder ?? '—')}
</td>
<td className="px-4 py-3 text-muted-foreground">
{mandate.mandate_date
? new Date(
String(mandate.mandate_date),
).toLocaleDateString('de-DE')
: '—'}
</td>
<td className="px-4 py-3">
<Badge variant={getMandateStatusColor(mandateStatus)}>
{MANDATE_STATUS_LABELS[mandateStatus] ?? mandateStatus}
</Badge>
</td>
<td className="px-4 py-3 text-center">
{isPrimary ? '✓' : '✗'}
</td>
<td className="px-4 py-3 text-right">
{canRevoke && (
<Button
size="sm"
variant="destructive"
disabled={isRevoking}
onClick={() => handleRevoke(mandateId, reference)}
>
Widerrufen
</Button>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,227 @@
'use client';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { toast } from '@kit/ui/sonner';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
STATUS_LABELS,
getMemberStatusColor,
formatAddress,
formatIban,
computeAge,
computeMembershipYears,
} from '../lib/member-utils';
import { deleteMember, updateMember } from '../server/actions/member-actions';
interface MemberDetailViewProps {
member: Record<string, unknown>;
account: string;
accountId: string;
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-start justify-between gap-4 border-b py-2 last:border-b-0">
<span className="text-sm font-medium text-muted-foreground">{label}</span>
<span className="text-sm text-right">{value ?? '—'}</span>
</div>
);
}
export function MemberDetailView({ member, account, accountId }: MemberDetailViewProps) {
const router = useRouter();
const memberId = String(member.id ?? '');
const status = String(member.status ?? 'active');
const firstName = String(member.first_name ?? '');
const lastName = String(member.last_name ?? '');
const fullName = `${firstName} ${lastName}`.trim();
const form = useForm();
const { execute: executeDelete, isPending: isDeleting } = useAction(deleteMember, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Mitglied wurde gekündigt');
router.push(`/home/${account}/members-cms`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Kündigen');
},
});
const { execute: executeUpdate, isPending: isUpdating } = useAction(updateMember, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Mitglied wurde archiviert');
router.refresh();
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Archivieren');
},
});
const handleDelete = useCallback(() => {
if (!window.confirm(`Möchten Sie ${fullName} wirklich kündigen? Diese Aktion kann nicht rückgängig gemacht werden.`)) {
return;
}
executeDelete({ memberId, accountId });
}, [executeDelete, memberId, accountId, fullName]);
const handleArchive = useCallback(() => {
if (!window.confirm(`Möchten Sie ${fullName} wirklich archivieren?`)) {
return;
}
executeUpdate({
memberId,
accountId,
isArchived: true,
});
}, [executeUpdate, memberId, accountId, fullName]);
const age = computeAge(member.date_of_birth as string | null | undefined);
const membershipYears = computeMembershipYears(member.entry_date as string | null | undefined);
const address = formatAddress(member);
const iban = formatIban(member.iban as string | null | undefined);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{fullName}</h1>
<Badge variant={getMemberStatusColor(status)}>
{STATUS_LABELS[status] ?? status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
Mitgliedsnr. {String(member.member_number ?? '—')}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() =>
router.push(`/home/${account}/members-cms/${memberId}/edit`)
}
>
Bearbeiten
</Button>
<Button
variant="outline"
disabled={isUpdating}
onClick={handleArchive}
>
{isUpdating ? 'Archiviere...' : 'Archivieren'}
</Button>
<Button
variant="destructive"
disabled={isDeleting}
onClick={handleDelete}
>
{isDeleting ? 'Wird gekündigt...' : 'Kündigen'}
</Button>
</div>
</div>
{/* Detail Cards */}
<div className="grid gap-6 md:grid-cols-2">
{/* Persönliche Daten */}
<Card>
<CardHeader>
<CardTitle>Persönliche Daten</CardTitle>
</CardHeader>
<CardContent>
<DetailRow label="Vorname" value={firstName} />
<DetailRow label="Nachname" value={lastName} />
<DetailRow
label="Geburtsdatum"
value={
member.date_of_birth
? `${new Date(String(member.date_of_birth)).toLocaleDateString('de-DE')}${age !== null ? ` (${age} Jahre)` : ''}`
: null
}
/>
<DetailRow label="Geschlecht" value={String(member.gender ?? '—')} />
<DetailRow label="Anrede" value={String(member.salutation ?? '—')} />
</CardContent>
</Card>
{/* Kontakt */}
<Card>
<CardHeader>
<CardTitle>Kontakt</CardTitle>
</CardHeader>
<CardContent>
<DetailRow label="E-Mail" value={String(member.email ?? '—')} />
<DetailRow label="Telefon" value={String(member.phone ?? '—')} />
<DetailRow label="Mobil" value={String(member.mobile ?? '—')} />
</CardContent>
</Card>
{/* Adresse */}
<Card>
<CardHeader>
<CardTitle>Adresse</CardTitle>
</CardHeader>
<CardContent>
<DetailRow label="Adresse" value={address || '—'} />
<DetailRow label="PLZ" value={String(member.postal_code ?? '—')} />
<DetailRow label="Ort" value={String(member.city ?? '—')} />
<DetailRow label="Land" value={String(member.country ?? 'DE')} />
</CardContent>
</Card>
{/* Mitgliedschaft */}
<Card>
<CardHeader>
<CardTitle>Mitgliedschaft</CardTitle>
</CardHeader>
<CardContent>
<DetailRow label="Mitgliedsnr." value={String(member.member_number ?? '—')} />
<DetailRow
label="Status"
value={
<Badge variant={getMemberStatusColor(status)}>
{STATUS_LABELS[status] ?? status}
</Badge>
}
/>
<DetailRow
label="Eintrittsdatum"
value={
member.entry_date
? new Date(String(member.entry_date)).toLocaleDateString('de-DE')
: '—'
}
/>
<DetailRow
label="Mitgliedsjahre"
value={membershipYears > 0 ? `${membershipYears} Jahre` : '—'}
/>
<DetailRow label="IBAN" value={iban} />
<DetailRow label="BIC" value={String(member.bic ?? '—')} />
<DetailRow label="Kontoinhaber" value={String(member.account_holder ?? '—')} />
<DetailRow label="Notizen" value={String(member.notes ?? '—')} />
</CardContent>
</Card>
</div>
{/* Back */}
<div>
<Button variant="ghost" onClick={() => router.back()}>
Zurück zur Übersicht
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,281 @@
'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import Papa from 'papaparse';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Badge } from '@kit/ui/badge';
import { toast } from '@kit/ui/sonner';
import { useAction } from 'next-safe-action/hooks';
import { Upload, ArrowRight, ArrowLeft, CheckCircle, AlertTriangle } from 'lucide-react';
import { createMember } from '../server/actions/member-actions';
const MEMBER_FIELDS = [
{ key: 'memberNumber', label: 'Mitgliedsnr.' },
{ key: 'salutation', label: 'Anrede' },
{ key: 'firstName', label: 'Vorname' },
{ key: 'lastName', label: 'Nachname' },
{ key: 'dateOfBirth', label: 'Geburtsdatum' },
{ key: 'email', label: 'E-Mail' },
{ key: 'phone', label: 'Telefon' },
{ key: 'mobile', label: 'Mobil' },
{ key: 'street', label: 'Straße' },
{ key: 'houseNumber', label: 'Hausnummer' },
{ key: 'postalCode', label: 'PLZ' },
{ key: 'city', label: 'Ort' },
{ key: 'entryDate', label: 'Eintrittsdatum' },
{ key: 'iban', label: 'IBAN' },
{ key: 'bic', label: 'BIC' },
{ key: 'accountHolder', label: 'Kontoinhaber' },
{ key: 'notes', label: 'Notizen' },
] as const;
interface Props {
accountId: string;
account: string;
}
type Step = 'upload' | 'mapping' | 'preview' | 'importing' | 'done';
export function MemberImportWizard({ accountId, account }: Props) {
const router = useRouter();
const [step, setStep] = useState<Step>('upload');
const [rawData, setRawData] = useState<string[][]>([]);
const [headers, setHeaders] = useState<string[]>([]);
const [mapping, setMapping] = useState<Record<string, string>>({});
const [importResults, setImportResults] = useState<{ success: number; errors: string[] }>({ success: 0, errors: [] });
const { execute: executeCreate } = useAction(createMember);
// Step 1: Parse file
const handleFileUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
Papa.parse(file, {
delimiter: ';',
encoding: 'UTF-8',
complete: (result) => {
const data = result.data as string[][];
if (data.length < 2) {
toast.error('Datei enthält keine Daten');
return;
}
setHeaders(data[0]!);
setRawData(data.slice(1).filter(row => row.some(cell => cell?.trim())));
// Auto-map by header name similarity
const autoMap: Record<string, string> = {};
for (const field of MEMBER_FIELDS) {
const match = data[0]!.findIndex(h =>
h.toLowerCase().includes(field.label.toLowerCase().replace('.', '')) ||
h.toLowerCase().includes(field.key.toLowerCase())
);
if (match >= 0) autoMap[field.key] = String(match);
}
setMapping(autoMap);
setStep('mapping');
toast.success(`${data.length - 1} Zeilen erkannt`);
},
error: (err) => {
toast.error(`Fehler beim Lesen: ${err.message}`);
},
});
}, []);
// Step 3: Execute import
const executeImport = useCallback(async () => {
setStep('importing');
let success = 0;
const errors: string[] = [];
for (let i = 0; i < rawData.length; i++) {
const row = rawData[i]!;
try {
const memberData: Record<string, string> = { accountId };
for (const field of MEMBER_FIELDS) {
const colIdx = mapping[field.key];
if (colIdx !== undefined && row[Number(colIdx)]) {
memberData[field.key] = row[Number(colIdx)]!.trim();
}
}
if (!memberData.firstName || !memberData.lastName) {
errors.push(`Zeile ${i + 2}: Vor-/Nachname fehlt`);
continue;
}
await executeCreate(memberData as any);
success++;
} catch (err) {
errors.push(`Zeile ${i + 2}: ${err instanceof Error ? err.message : 'Unbekannter Fehler'}`);
}
}
setImportResults({ success, errors });
setStep('done');
toast.success(`${success} Mitglieder importiert`);
}, [rawData, mapping, accountId, executeCreate]);
const getMappedValue = (rowIdx: number, fieldKey: string): string => {
const colIdx = mapping[fieldKey];
if (colIdx === undefined) return '';
return rawData[rowIdx]?.[Number(colIdx)]?.trim() ?? '';
};
return (
<div className="space-y-6">
{/* Step indicator */}
<div className="flex items-center justify-center gap-2">
{(['upload', 'mapping', 'preview', 'done'] as const).map((s, i) => {
const labels = ['Datei hochladen', 'Spalten zuordnen', 'Vorschau & Import', 'Fertig'];
const isActive = ['upload', 'mapping', 'preview', 'importing', 'done'].indexOf(step) >= i;
return (
<div key={s} className="flex items-center gap-2">
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold ${
isActive ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
}`}>{i + 1}</div>
<span className={`text-sm ${isActive ? 'font-semibold' : 'text-muted-foreground'}`}>{labels[i]}</span>
{i < 3 && <ArrowRight className="h-4 w-4 text-muted-foreground" />}
</div>
);
})}
</div>
{/* Step 1: Upload */}
{step === 'upload' && (
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Upload className="h-4 w-4" />Datei hochladen</CardTitle></CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-12 text-center">
<Upload className="mb-4 h-10 w-10 text-muted-foreground" />
<p className="text-lg font-semibold">CSV-Datei auswählen</p>
<p className="mt-1 text-sm text-muted-foreground">Semikolon-getrennt (;), UTF-8</p>
<input type="file" accept=".csv" onChange={handleFileUpload}
className="mt-4 block w-full max-w-xs text-sm file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground" />
</div>
</CardContent>
</Card>
)}
{/* Step 2: Column mapping */}
{step === 'mapping' && (
<Card>
<CardHeader><CardTitle>Spalten zuordnen</CardTitle></CardHeader>
<CardContent>
<p className="mb-4 text-sm text-muted-foreground">{rawData.length} Zeilen erkannt. Ordnen Sie die CSV-Spalten den Mitgliedsfeldern zu.</p>
<div className="space-y-2">
{MEMBER_FIELDS.map(field => (
<div key={field.key} className="flex items-center gap-4">
<span className="w-40 text-sm font-medium">{field.label}</span>
<span className="text-muted-foreground"></span>
<select
value={mapping[field.key] ?? ''}
onChange={(e) => setMapping(prev => ({ ...prev, [field.key]: e.target.value }))}
className="flex h-9 w-64 rounded-md border border-input bg-background px-3 py-1 text-sm"
>
<option value=""> Nicht zuordnen </option>
{headers.map((h, i) => (
<option key={i} value={String(i)}>{h}</option>
))}
</select>
{mapping[field.key] !== undefined && rawData[0] && (
<span className="text-xs text-muted-foreground">z.B. &quot;{rawData[0][Number(mapping[field.key])]}&quot;</span>
)}
</div>
))}
</div>
<div className="mt-6 flex justify-between">
<Button variant="outline" onClick={() => setStep('upload')}><ArrowLeft className="mr-2 h-4 w-4" />Zurück</Button>
<Button onClick={() => setStep('preview')}>Vorschau <ArrowRight className="ml-2 h-4 w-4" /></Button>
</div>
</CardContent>
</Card>
)}
{/* Step 3: Preview + execute */}
{step === 'preview' && (
<Card>
<CardHeader><CardTitle>Vorschau ({rawData.length} Einträge)</CardTitle></CardHeader>
<CardContent>
<div className="overflow-auto rounded-md border max-h-96">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-2 text-left">#</th>
{MEMBER_FIELDS.filter(f => mapping[f.key] !== undefined).map(f => (
<th key={f.key} className="p-2 text-left">{f.label}</th>
))}
</tr>
</thead>
<tbody>
{rawData.slice(0, 20).map((_, i) => {
const hasName = getMappedValue(i, 'firstName') && getMappedValue(i, 'lastName');
return (
<tr key={i} className={`border-b ${hasName ? '' : 'bg-red-50 dark:bg-red-950'}`}>
<td className="p-2">{i + 1} {!hasName && <AlertTriangle className="inline h-3 w-3 text-destructive" />}</td>
{MEMBER_FIELDS.filter(f => mapping[f.key] !== undefined).map(f => (
<td key={f.key} className="p-2 max-w-32 truncate">{getMappedValue(i, f.key) || '—'}</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
{rawData.length > 20 && <p className="mt-2 text-xs text-muted-foreground">... und {rawData.length - 20} weitere Einträge</p>}
<div className="mt-6 flex justify-between">
<Button variant="outline" onClick={() => setStep('mapping')}><ArrowLeft className="mr-2 h-4 w-4" />Zurück</Button>
<Button onClick={executeImport}>
<CheckCircle className="mr-2 h-4 w-4" />
{rawData.length} Mitglieder importieren
</Button>
</div>
</CardContent>
</Card>
)}
{/* Importing */}
{step === 'importing' && (
<Card>
<CardContent className="flex flex-col items-center justify-center p-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="mt-4 text-lg font-semibold">Importiere Mitglieder...</p>
<p className="text-sm text-muted-foreground">Bitte warten Sie, bis der Import abgeschlossen ist.</p>
</CardContent>
</Card>
)}
{/* Done */}
{step === 'done' && (
<Card>
<CardContent className="p-8 text-center">
<CheckCircle className="mx-auto h-12 w-12 text-green-500" />
<h3 className="mt-4 text-xl font-semibold">Import abgeschlossen</h3>
<div className="mt-4 flex justify-center gap-4">
<Badge variant="default">{importResults.success} erfolgreich</Badge>
{importResults.errors.length > 0 && (
<Badge variant="destructive">{importResults.errors.length} Fehler</Badge>
)}
</div>
{importResults.errors.length > 0 && (
<div className="mt-4 max-h-40 overflow-auto rounded-md border p-3 text-left text-xs">
{importResults.errors.map((err, i) => (
<p key={i} className="text-destructive">{err}</p>
))}
</div>
)}
<div className="mt-6">
<Button onClick={() => router.push(`/home/${account}/members-cms`)}>
Zur Mitgliederliste
</Button>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,230 @@
'use client';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter, useSearchParams } from 'next/navigation';
import { toast } from '@kit/ui/sonner';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { STATUS_LABELS, getMemberStatusColor } from '../lib/member-utils';
interface MembersDataTableProps {
data: Array<Record<string, unknown>>;
total: number;
page: number;
pageSize: number;
account: string;
duesCategories: Array<{ id: string; name: string }>;
}
const STATUS_OPTIONS = [
{ value: '', label: 'Alle' },
{ value: 'active', label: 'Aktiv' },
{ value: 'inactive', label: 'Inaktiv' },
{ value: 'pending', label: 'Ausstehend' },
{ value: 'resigned', label: 'Ausgetreten' },
] as const;
export function MembersDataTable({
data,
total,
page,
pageSize,
account,
duesCategories,
}: MembersDataTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
const currentSearch = searchParams.get('search') ?? '';
const currentStatus = searchParams.get('status') ?? '';
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const form = useForm({
defaultValues: {
search: currentSearch,
},
});
const updateParams = useCallback(
(updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
}
// Reset to page 1 on filter change
if (!('page' in updates)) {
params.delete('page');
}
router.push(`?${params.toString()}`);
},
[router, searchParams],
);
const handleSearch = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
const search = form.getValues('search');
updateParams({ search });
},
[form, updateParams],
);
const handleStatusChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
updateParams({ status: e.target.value });
},
[updateParams],
);
const handlePageChange = useCallback(
(newPage: number) => {
updateParams({ page: String(newPage) });
},
[updateParams],
);
const handleRowClick = useCallback(
(memberId: string) => {
router.push(`/home/${account}/members-cms/${memberId}`);
},
[router, account],
);
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<form onSubmit={handleSearch} className="flex gap-2">
<Input
placeholder="Mitglied suchen..."
className="w-64"
{...form.register('search')}
/>
<Button type="submit" variant="outline" size="sm">
Suchen
</Button>
</form>
<div className="flex items-center gap-3">
<select
value={currentStatus}
onChange={handleStatusChange}
className="flex h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<Button
size="sm"
onClick={() =>
router.push(`/home/${account}/members-cms/new`)
}
>
Neues Mitglied
</Button>
</div>
</div>
{/* Table */}
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">Nr</th>
<th className="px-4 py-3 text-left font-medium">Name</th>
<th className="px-4 py-3 text-left font-medium">E-Mail</th>
<th className="px-4 py-3 text-left font-medium">Ort</th>
<th className="px-4 py-3 text-left font-medium">Status</th>
<th className="px-4 py-3 text-left font-medium">Eintritt</th>
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
Keine Mitglieder gefunden.
</td>
</tr>
) : (
data.map((member) => {
const memberId = String(member.id ?? '');
const status = String(member.status ?? 'active');
return (
<tr
key={memberId}
onClick={() => handleRowClick(memberId)}
className="cursor-pointer border-b transition-colors hover:bg-muted/50"
>
<td className="px-4 py-3 font-mono text-xs">
{String(member.member_number ?? '—')}
</td>
<td className="px-4 py-3">
{String(member.last_name ?? '')},{' '}
{String(member.first_name ?? '')}
</td>
<td className="px-4 py-3 text-muted-foreground">
{String(member.email ?? '—')}
</td>
<td className="px-4 py-3">
{String(member.city ?? '—')}
</td>
<td className="px-4 py-3">
<Badge variant={getMemberStatusColor(status)}>
{STATUS_LABELS[status] ?? status}
</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">
{member.entry_date
? new Date(String(member.entry_date)).toLocaleDateString('de-DE')
: '—'}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{total} Mitglied{total !== 1 ? 'er' : ''} insgesamt
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => handlePageChange(page - 1)}
>
Zurück
</Button>
<span className="text-sm">
Seite {page} von {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
>
Weiter
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
/**
* Client-side utility functions for member display.
*/
export function computeAge(dateOfBirth: string | null | undefined): number | null {
if (!dateOfBirth) return null;
const birth = new Date(dateOfBirth);
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const m = today.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
return age;
}
export function computeMembershipYears(entryDate: string | null | undefined): number {
if (!entryDate) return 0;
const entry = new Date(entryDate);
const today = new Date();
let years = today.getFullYear() - entry.getFullYear();
const m = today.getMonth() - entry.getMonth();
if (m < 0 || (m === 0 && today.getDate() < entry.getDate())) years--;
return Math.max(0, years);
}
export function formatSalutation(salutation: string | null | undefined, firstName: string, lastName: string): string {
if (salutation) return `${salutation} ${firstName} ${lastName}`;
return `${firstName} ${lastName}`;
}
export function formatAddress(member: Record<string, unknown>): string {
const parts: string[] = [];
if (member.street) {
let line = String(member.street);
if (member.house_number) line += ` ${member.house_number}`;
parts.push(line);
}
if (member.street2) parts.push(String(member.street2));
if (member.postal_code || member.city) {
parts.push(`${member.postal_code ?? ''} ${member.city ?? ''}`.trim());
}
return parts.join(', ');
}
export function formatIban(iban: string | null | undefined): string {
if (!iban) return '—';
const cleaned = iban.replace(/\s/g, '');
return cleaned.replace(/(.{4})/g, '$1 ').trim();
}
export function getMemberStatusColor(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case 'active': return 'default';
case 'inactive': return 'secondary';
case 'pending': return 'outline';
case 'resigned':
case 'excluded':
case 'deceased': return 'destructive';
default: return 'secondary';
}
}
export const STATUS_LABELS: Record<string, string> = {
active: 'Aktiv',
inactive: 'Inaktiv',
pending: 'Ausstehend',
resigned: 'Ausgetreten',
excluded: 'Ausgeschlossen',
deceased: 'Verstorben',
};

View File

@@ -33,12 +33,42 @@ export const CreateMemberSchema = z.object({
accountHolder: z.string().max(128).optional(),
gdprConsent: z.boolean().default(false),
notes: z.string().optional(),
// New optional fields
salutation: z.string().optional(),
street2: z.string().optional(),
phone2: z.string().optional(),
fax: z.string().optional(),
birthplace: z.string().optional(),
birthCountry: z.string().default('DE'),
isHonorary: z.boolean().default(false),
isFoundingMember: z.boolean().default(false),
isYouth: z.boolean().default(false),
isRetiree: z.boolean().default(false),
isProbationary: z.boolean().default(false),
isTransferred: z.boolean().default(false),
exitDate: z.string().optional(),
exitReason: z.string().optional(),
guardianName: z.string().optional(),
guardianPhone: z.string().optional(),
guardianEmail: z.string().optional(),
duesYear: z.number().int().optional(),
duesPaid: z.boolean().default(false),
additionalFees: z.number().default(0),
exemptionType: z.string().optional(),
exemptionReason: z.string().optional(),
exemptionAmount: z.number().optional(),
gdprNewsletter: z.boolean().default(false),
gdprInternet: z.boolean().default(false),
gdprPrint: z.boolean().default(false),
gdprBirthdayInfo: z.boolean().default(false),
sepaMandateReference: z.string().optional(),
});
export type CreateMemberInput = z.infer<typeof CreateMemberSchema>;
export const UpdateMemberSchema = CreateMemberSchema.partial().extend({
memberId: z.string().uuid(),
isArchived: z.boolean().optional(),
});
export type UpdateMemberInput = z.infer<typeof UpdateMemberSchema>;
@@ -50,6 +80,77 @@ export const CreateDuesCategorySchema = z.object({
amount: z.number().min(0),
interval: z.enum(['monthly', 'quarterly', 'half_yearly', 'yearly']).default('yearly'),
isDefault: z.boolean().default(false),
isYouth: z.boolean().default(false),
isExit: z.boolean().default(false),
});
export type CreateDuesCategoryInput = z.infer<typeof CreateDuesCategorySchema>;
export const RejectApplicationSchema = z.object({
applicationId: z.string().uuid(),
accountId: z.string().uuid(),
reviewNotes: z.string().optional(),
});
export const CreateDepartmentSchema = z.object({
accountId: z.string().uuid(),
name: z.string().min(1).max(128),
description: z.string().optional(),
});
export const CreateMemberRoleSchema = z.object({
memberId: z.string().uuid(),
accountId: z.string().uuid(),
roleName: z.string().min(1),
fromDate: z.string().optional(),
untilDate: z.string().optional(),
});
export const CreateMemberHonorSchema = z.object({
memberId: z.string().uuid(),
accountId: z.string().uuid(),
honorName: z.string().min(1),
honorDate: z.string().optional(),
description: z.string().optional(),
});
export const CreateSepaMandateSchema = z.object({
memberId: z.string().uuid(),
accountId: z.string().uuid(),
mandateReference: z.string().min(1),
iban: z.string().min(15).max(34),
bic: z.string().optional(),
accountHolder: z.string().min(1),
mandateDate: z.string(),
sequence: z.enum(['FRST', 'RCUR', 'FNAL', 'OOFF']).default('RCUR'),
});
export const UpdateDuesCategorySchema = z.object({
categoryId: z.string().uuid(),
name: z.string().min(1).optional(),
description: z.string().optional(),
amount: z.number().min(0).optional(),
interval: z.enum(['monthly', 'quarterly', 'half_yearly', 'yearly']).optional(),
isDefault: z.boolean().optional(),
});
export type UpdateDuesCategoryInput = z.infer<typeof UpdateDuesCategorySchema>;
export const UpdateMandateSchema = z.object({
mandateId: z.string().uuid(),
iban: z.string().min(15).max(34).optional(),
bic: z.string().optional(),
accountHolder: z.string().optional(),
sequence: z.enum(['FRST', 'RCUR', 'FNAL', 'OOFF']).optional(),
});
export type UpdateMandateInput = z.infer<typeof UpdateMandateSchema>;
export const ExportMembersSchema = z.object({
accountId: z.string().uuid(),
status: z.string().optional(),
format: z.enum(['csv', 'excel']).default('csv'),
});
export const AssignDepartmentSchema = z.object({
memberId: z.string().uuid(),
departmentId: z.string().uuid(),
});

View File

@@ -0,0 +1,302 @@
'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
CreateMemberSchema,
UpdateMemberSchema,
RejectApplicationSchema,
CreateDuesCategorySchema,
CreateDepartmentSchema,
CreateMemberRoleSchema,
CreateMemberHonorSchema,
CreateSepaMandateSchema,
UpdateDuesCategorySchema,
UpdateMandateSchema,
ExportMembersSchema,
AssignDepartmentSchema,
} from '../../schema/member.schema';
import { createMemberManagementApi } from '../api';
export const createMember = authActionClient
.inputSchema(CreateMemberSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
const userId = ctx.user.id;
logger.info({ name: 'member.create' }, 'Creating member...');
const result = await api.createMember(input, userId);
logger.info({ name: 'member.create' }, 'Member created');
return { success: true, data: result };
});
export const updateMember = authActionClient
.inputSchema(UpdateMemberSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
const userId = ctx.user.id;
logger.info({ name: 'member.update' }, 'Updating member...');
const result = await api.updateMember(input, userId);
logger.info({ name: 'member.update' }, 'Member updated');
return { success: true, data: result };
});
export const deleteMember = authActionClient
.inputSchema(
z.object({
memberId: z.string().uuid(),
accountId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
logger.info({ name: 'member.delete' }, 'Deleting member...');
const result = await api.deleteMember(input.memberId);
logger.info({ name: 'member.delete' }, 'Member deleted');
return { success: true, data: result };
});
export const approveApplication = authActionClient
.inputSchema(
z.object({
applicationId: z.string().uuid(),
accountId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
const userId = ctx.user.id;
logger.info({ name: 'member.approveApplication' }, 'Approving application...');
const result = await api.approveApplication(input.applicationId, userId);
logger.info({ name: 'member.approveApplication' }, 'Application approved');
return { success: true, data: result };
});
export const rejectApplication = authActionClient
.inputSchema(RejectApplicationSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
logger.info({ name: 'members.reject-application' }, 'Rejecting application...');
await api.rejectApplication(input.applicationId, ctx.user.id, input.reviewNotes);
return { success: true };
});
export const createDuesCategory = authActionClient
.inputSchema(CreateDuesCategorySchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.createDuesCategory(input);
return { success: true, data };
});
export const deleteDuesCategory = authActionClient
.inputSchema(z.object({ categoryId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.deleteDuesCategory(input.categoryId);
return { success: true };
});
export const createDepartment = authActionClient
.inputSchema(CreateDepartmentSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.createDepartment(input);
return { success: true, data };
});
export const createMemberRole = authActionClient
.inputSchema(CreateMemberRoleSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.createMemberRole(input);
return { success: true, data };
});
export const deleteMemberRole = authActionClient
.inputSchema(z.object({ roleId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.deleteMemberRole(input.roleId);
return { success: true };
});
export const createMemberHonor = authActionClient
.inputSchema(CreateMemberHonorSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.createMemberHonor(input);
return { success: true, data };
});
export const deleteMemberHonor = authActionClient
.inputSchema(z.object({ honorId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.deleteMemberHonor(input.honorId);
return { success: true };
});
export const createMandate = authActionClient
.inputSchema(CreateSepaMandateSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.createMandate(input);
return { success: true, data };
});
export const revokeMandate = authActionClient
.inputSchema(z.object({ mandateId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.revokeMandate(input.mandateId);
return { success: true };
});
// Gap 1: Update operations
export const updateDuesCategory = authActionClient
.inputSchema(UpdateDuesCategorySchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.updateDuesCategory(input);
return { success: true, data };
});
export const updateMandate = authActionClient
.inputSchema(UpdateMandateSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.updateMandate(input);
return { success: true, data };
});
// Gap 2: Export
export const exportMembers = authActionClient
.inputSchema(ExportMembersSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const csv = await api.exportMembersCsv(input.accountId, { status: input.status });
return { success: true, csv };
});
// Gap 5: Department assignments
export const assignDepartment = authActionClient
.inputSchema(AssignDepartmentSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.assignDepartment(input.memberId, input.departmentId);
return { success: true };
});
export const removeDepartment = authActionClient
.inputSchema(AssignDepartmentSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.removeDepartment(input.memberId, input.departmentId);
return { success: true };
});
// Gap 2: Excel export
export const exportMembersExcel = authActionClient
.inputSchema(ExportMembersSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const buffer = await api.exportMembersExcel(input.accountId, { status: input.status });
// Return base64 for client-side download
return { success: true, base64: buffer.toString('base64'), filename: `mitglieder_${new Date().toISOString().split('T')[0]}.xlsx` };
});
// Gap 6: Member card PDF generation
export const generateMemberCards = authActionClient
.inputSchema(z.object({
accountId: z.string().uuid(),
memberIds: z.array(z.string().uuid()).optional(),
orgName: z.string().default('Verein'),
}))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
let query = client.from('members').select('id, first_name, last_name, member_number, entry_date, status')
.eq('account_id', input.accountId).eq('status', 'active');
if (input.memberIds && input.memberIds.length > 0) {
query = query.in('id', input.memberIds);
}
const { data: members, error } = await query;
if (error) throw error;
const { generateMemberCardsPdf } = await import('../services/member-card-generator');
const buffer = await generateMemberCardsPdf(
input.orgName,
(members ?? []).map((m: any) => ({
firstName: m.first_name, lastName: m.last_name,
memberNumber: m.member_number ?? '', entryDate: m.entry_date ?? '',
status: m.status,
})),
);
return { success: true, base64: buffer.toString('base64'), filename: `mitgliedsausweise_${new Date().toISOString().split('T')[0]}.pdf` };
});
// Portal Invitations
export const inviteMemberToPortal = authActionClient
.inputSchema(z.object({
memberId: z.string().uuid(),
accountId: z.string().uuid(),
email: z.string().email(),
}))
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
logger.info({ name: 'portal.invite', memberId: input.memberId }, 'Sending portal invitation...');
const invitation = await api.inviteMemberToPortal(input, ctx.user.id);
// Create auth user for the member if not exists
// In production: send invitation email with the token link
// For now: create the user directly via admin API
logger.info({ name: 'portal.invite', token: invitation.invite_token }, 'Invitation created');
return { success: true, data: invitation };
});
export const revokePortalInvitation = authActionClient
.inputSchema(z.object({ invitationId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.revokePortalInvitation(input.invitationId);
return { success: true };
});

View File

@@ -65,6 +65,33 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
gdpr_consent: input.gdprConsent,
gdpr_consent_date: input.gdprConsent ? new Date().toISOString() : null,
notes: input.notes,
// New parity fields
salutation: input.salutation,
street2: input.street2,
phone2: input.phone2,
fax: input.fax,
birthplace: input.birthplace,
birth_country: input.birthCountry,
is_honorary: input.isHonorary,
is_founding_member: input.isFoundingMember,
is_youth: input.isYouth,
is_retiree: input.isRetiree,
is_probationary: input.isProbationary,
is_transferred: input.isTransferred,
guardian_name: input.guardianName,
guardian_phone: input.guardianPhone,
guardian_email: input.guardianEmail,
dues_year: input.duesYear,
dues_paid: input.duesPaid,
additional_fees: input.additionalFees,
exemption_type: input.exemptionType,
exemption_reason: input.exemptionReason,
exemption_amount: input.exemptionAmount,
gdpr_newsletter: input.gdprNewsletter,
gdpr_internet: input.gdprInternet,
gdpr_print: input.gdprPrint,
gdpr_birthday_info: input.gdprBirthdayInfo,
sepa_mandate_reference: input.sepaMandateReference,
created_by: userId,
updated_by: userId,
})
@@ -89,7 +116,45 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
if (input.status !== undefined) updateData.status = input.status;
if (input.duesCategoryId !== undefined) updateData.dues_category_id = input.duesCategoryId;
if (input.iban !== undefined) updateData.iban = input.iban;
if (input.bic !== undefined) updateData.bic = input.bic;
if (input.accountHolder !== undefined) updateData.account_holder = input.accountHolder;
if (input.notes !== undefined) updateData.notes = input.notes;
if (input.isArchived !== undefined) updateData.is_archived = input.isArchived;
// New parity fields
if (input.salutation !== undefined) updateData.salutation = input.salutation;
if (input.street2 !== undefined) updateData.street2 = input.street2;
if (input.phone2 !== undefined) updateData.phone2 = input.phone2;
if (input.fax !== undefined) updateData.fax = input.fax;
if (input.birthplace !== undefined) updateData.birthplace = input.birthplace;
if (input.birthCountry !== undefined) updateData.birth_country = input.birthCountry;
if (input.title !== undefined) updateData.title = input.title;
if (input.dateOfBirth !== undefined) updateData.date_of_birth = input.dateOfBirth;
if (input.gender !== undefined) updateData.gender = input.gender;
if (input.country !== undefined) updateData.country = input.country;
if (input.entryDate !== undefined) updateData.entry_date = input.entryDate;
if (input.exitDate !== undefined) updateData.exit_date = input.exitDate;
if (input.exitReason !== undefined) updateData.exit_reason = input.exitReason;
if (input.isHonorary !== undefined) updateData.is_honorary = input.isHonorary;
if (input.isFoundingMember !== undefined) updateData.is_founding_member = input.isFoundingMember;
if (input.isYouth !== undefined) updateData.is_youth = input.isYouth;
if (input.isRetiree !== undefined) updateData.is_retiree = input.isRetiree;
if (input.isProbationary !== undefined) updateData.is_probationary = input.isProbationary;
if (input.isTransferred !== undefined) updateData.is_transferred = input.isTransferred;
if (input.guardianName !== undefined) updateData.guardian_name = input.guardianName;
if (input.guardianPhone !== undefined) updateData.guardian_phone = input.guardianPhone;
if (input.guardianEmail !== undefined) updateData.guardian_email = input.guardianEmail;
if (input.duesYear !== undefined) updateData.dues_year = input.duesYear;
if (input.duesPaid !== undefined) updateData.dues_paid = input.duesPaid;
if (input.additionalFees !== undefined) updateData.additional_fees = input.additionalFees;
if (input.exemptionType !== undefined) updateData.exemption_type = input.exemptionType;
if (input.exemptionReason !== undefined) updateData.exemption_reason = input.exemptionReason;
if (input.exemptionAmount !== undefined) updateData.exemption_amount = input.exemptionAmount;
if (input.gdprConsent !== undefined) updateData.gdpr_consent = input.gdprConsent;
if (input.gdprNewsletter !== undefined) updateData.gdpr_newsletter = input.gdprNewsletter;
if (input.gdprInternet !== undefined) updateData.gdpr_internet = input.gdprInternet;
if (input.gdprPrint !== undefined) updateData.gdpr_print = input.gdprPrint;
if (input.gdprBirthdayInfo !== undefined) updateData.gdpr_birthday_info = input.gdprBirthdayInfo;
if (input.sepaMandateReference !== undefined) updateData.sepa_mandate_reference = input.sepaMandateReference;
const { data, error } = await (client).from('members')
.update(updateData)
@@ -186,5 +251,253 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
return member;
},
async rejectApplication(applicationId: string, userId: string, reviewNotes?: string) {
const { error } = await client.from('membership_applications')
.update({ status: 'rejected' as any, reviewed_by: userId, reviewed_at: new Date().toISOString(), review_notes: reviewNotes })
.eq('id', applicationId);
if (error) throw error;
},
async createDuesCategory(input: { accountId: string; name: string; description?: string; amount: number; interval?: string; isDefault?: boolean; isYouth?: boolean; isExit?: boolean }) {
const { data, error } = await client.from('dues_categories').insert({
account_id: input.accountId, name: input.name, description: input.description,
amount: input.amount, interval: input.interval ?? 'yearly',
is_default: input.isDefault ?? false, is_youth: input.isYouth ?? false, is_exit: input.isExit ?? false,
}).select().single();
if (error) throw error;
return data;
},
async deleteDuesCategory(categoryId: string) {
const { error } = await client.from('dues_categories').delete().eq('id', categoryId);
if (error) throw error;
},
async listDepartments(accountId: string) {
const { data, error } = await client.from('member_departments').select('*')
.eq('account_id', accountId).order('sort_order');
if (error) throw error;
return data ?? [];
},
async createDepartment(input: { accountId: string; name: string; description?: string }) {
const { data, error } = await client.from('member_departments').insert({
account_id: input.accountId, name: input.name, description: input.description,
}).select().single();
if (error) throw error;
return data;
},
async assignDepartment(memberId: string, departmentId: string) {
const { error } = await client.from('member_department_assignments').insert({
member_id: memberId, department_id: departmentId,
});
if (error) throw error;
},
async removeDepartment(memberId: string, departmentId: string) {
const { error } = await client.from('member_department_assignments').delete()
.eq('member_id', memberId).eq('department_id', departmentId);
if (error) throw error;
},
async listMemberRoles(memberId: string) {
const { data, error } = await client.from('member_roles').select('*')
.eq('member_id', memberId).order('from_date', { ascending: false });
if (error) throw error;
return data ?? [];
},
async createMemberRole(input: { memberId: string; accountId: string; roleName: string; fromDate?: string; untilDate?: string }) {
const { data, error } = await client.from('member_roles').insert({
member_id: input.memberId, account_id: input.accountId, role_name: input.roleName,
from_date: input.fromDate, until_date: input.untilDate,
}).select().single();
if (error) throw error;
return data;
},
async deleteMemberRole(roleId: string) {
const { error } = await client.from('member_roles').delete().eq('id', roleId);
if (error) throw error;
},
async listMemberHonors(memberId: string) {
const { data, error } = await client.from('member_honors').select('*')
.eq('member_id', memberId).order('honor_date', { ascending: false });
if (error) throw error;
return data ?? [];
},
async createMemberHonor(input: { memberId: string; accountId: string; honorName: string; honorDate?: string; description?: string }) {
const { data, error } = await client.from('member_honors').insert({
member_id: input.memberId, account_id: input.accountId, honor_name: input.honorName,
honor_date: input.honorDate, description: input.description,
}).select().single();
if (error) throw error;
return data;
},
async deleteMemberHonor(honorId: string) {
const { error } = await client.from('member_honors').delete().eq('id', honorId);
if (error) throw error;
},
async listMandates(memberId: string) {
const { data, error } = await client.from('sepa_mandates').select('*')
.eq('member_id', memberId).order('is_primary', { ascending: false });
if (error) throw error;
return data ?? [];
},
async createMandate(input: { memberId: string; accountId: string; mandateReference: string; iban: string; bic?: string; accountHolder: string; mandateDate: string; sequence?: string }) {
const { data, error } = await client.from('sepa_mandates').insert({
member_id: input.memberId, account_id: input.accountId,
mandate_reference: input.mandateReference, iban: input.iban, bic: input.bic,
account_holder: input.accountHolder, mandate_date: input.mandateDate,
sequence: input.sequence ?? 'RCUR',
}).select().single();
if (error) throw error;
return data;
},
async revokeMandate(mandateId: string) {
const { error } = await client.from('sepa_mandates')
.update({ status: 'revoked' as any }).eq('id', mandateId);
if (error) throw error;
},
async checkDuplicate(accountId: string, firstName: string, lastName: string, dateOfBirth?: string) {
const { data, error } = await client.rpc('check_duplicate_member', {
p_account_id: accountId, p_first_name: firstName, p_last_name: lastName,
p_date_of_birth: dateOfBirth ?? undefined,
});
if (error) throw error;
return data ?? [];
},
// --- Update operations (Gap 1) ---
async updateDuesCategory(input: { categoryId: string; name?: string; description?: string; amount?: number; interval?: string; isDefault?: boolean }) {
const updateData: Record<string, unknown> = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.description !== undefined) updateData.description = input.description;
if (input.amount !== undefined) updateData.amount = input.amount;
if (input.interval !== undefined) updateData.interval = input.interval;
if (input.isDefault !== undefined) updateData.is_default = input.isDefault;
const { data, error } = await client.from('dues_categories').update(updateData).eq('id', input.categoryId).select().single();
if (error) throw error;
return data;
},
async updateMandate(input: { mandateId: string; iban?: string; bic?: string; accountHolder?: string; sequence?: string }) {
const updateData: Record<string, unknown> = {};
if (input.iban !== undefined) updateData.iban = input.iban;
if (input.bic !== undefined) updateData.bic = input.bic;
if (input.accountHolder !== undefined) updateData.account_holder = input.accountHolder;
if (input.sequence !== undefined) updateData.sequence = input.sequence;
const { data, error } = await client.from('sepa_mandates').update(updateData).eq('id', input.mandateId).select().single();
if (error) throw error;
return data;
},
// --- Export (Gap 2) ---
async exportMembersCsv(accountId: string, filters?: { status?: string }) {
let query = client.from('members').select('*').eq('account_id', accountId).order('last_name');
if (filters?.status) query = query.eq('status', filters.status as any);
const { data, error } = await query;
if (error) throw error;
const members = data ?? [];
if (members.length === 0) return '';
const headers = ['Mitgliedsnr.', 'Anrede', 'Vorname', 'Nachname', 'Geburtsdatum', 'E-Mail', 'Telefon', 'Mobil', 'Straße', 'Hausnummer', 'PLZ', 'Ort', 'Status', 'Eintrittsdatum', 'IBAN', 'BIC', 'Kontoinhaber'];
const rows = members.map((m) => [
m.member_number ?? '', m.salutation ?? '', m.first_name, m.last_name,
m.date_of_birth ?? '', m.email ?? '', m.phone ?? '', m.mobile ?? '',
m.street ?? '', m.house_number ?? '', m.postal_code ?? '', m.city ?? '',
m.status, m.entry_date ?? '', m.iban ?? '', m.bic ?? '', m.account_holder ?? '',
].map(v => `"${String(v).replace(/"/g, '""')}"`).join(';'));
return [headers.join(';'), ...rows].join('\n');
},
// --- Department assign/remove (Gap 5) ---
async getDepartmentAssignments(memberId: string) {
const { data, error } = await client.from('member_department_assignments').select('department_id').eq('member_id', memberId);
if (error) throw error;
return (data ?? []).map((d) => d.department_id);
},
async exportMembersExcel(accountId: string, filters?: { status?: string }): Promise<Buffer> {
let query = client.from('members').select('*').eq('account_id', accountId).order('last_name');
if (filters?.status) query = query.eq('status', filters.status as any);
const { data, error } = await query;
if (error) throw error;
const members = data ?? [];
const ExcelJS = (await import('exceljs')).default;
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Mitglieder');
sheet.columns = [
{ header: 'Mitgliedsnr.', key: 'member_number', width: 15 },
{ header: 'Anrede', key: 'salutation', width: 10 },
{ header: 'Vorname', key: 'first_name', width: 20 },
{ header: 'Nachname', key: 'last_name', width: 20 },
{ header: 'Geburtsdatum', key: 'date_of_birth', width: 15 },
{ header: 'E-Mail', key: 'email', width: 30 },
{ header: 'Telefon', key: 'phone', width: 18 },
{ header: 'Mobil', key: 'mobile', width: 18 },
{ header: 'Straße', key: 'street', width: 25 },
{ header: 'Hausnummer', key: 'house_number', width: 12 },
{ header: 'PLZ', key: 'postal_code', width: 10 },
{ header: 'Ort', key: 'city', width: 20 },
{ header: 'Status', key: 'status', width: 12 },
{ header: 'Eintrittsdatum', key: 'entry_date', width: 15 },
{ header: 'IBAN', key: 'iban', width: 30 },
{ header: 'BIC', key: 'bic', width: 15 },
{ header: 'Kontoinhaber', key: 'account_holder', width: 25 },
];
// Style header row
sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8F5E9' } };
for (const m of members) {
sheet.addRow(m);
}
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
},
// --- Portal Invitations ---
async inviteMemberToPortal(input: { memberId: string; accountId: string; email: string }, invitedBy: string) {
const { data, error } = await client.from('member_portal_invitations').insert({
account_id: input.accountId, member_id: input.memberId, email: input.email, invited_by: invitedBy,
}).select().single();
if (error) throw error;
return data;
},
async listPortalInvitations(accountId: string) {
const { data, error } = await client.from('member_portal_invitations').select('*')
.eq('account_id', accountId).order('created_at', { ascending: false });
if (error) throw error;
return data ?? [];
},
async revokePortalInvitation(invitationId: string) {
const { error } = await client.from('member_portal_invitations')
.update({ status: 'revoked' as any }).eq('id', invitationId);
if (error) throw error;
},
async getMemberByUserId(accountId: string, userId: string) {
const { data, error } = await client.from('members').select('*')
.eq('account_id', accountId).eq('user_id', userId).maybeSingle();
if (error) throw error;
return data;
},
};
}

View File

@@ -0,0 +1,79 @@
/**
* Member card PDF generator using @react-pdf/renderer.
* Generates A4 pages with member ID cards in a grid layout.
*/
import { renderToBuffer } from '@react-pdf/renderer';
import { Document, Page, View, Text, StyleSheet } from '@react-pdf/renderer';
import React from 'react';
const styles = StyleSheet.create({
page: { padding: 20, flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
card: {
width: '48%', height: 180, border: '1pt solid #ccc', borderRadius: 8,
padding: 12, justifyContent: 'space-between',
},
orgName: { fontSize: 10, fontWeight: 'bold', color: '#0d9488', marginBottom: 6 },
cardTitle: { fontSize: 7, color: '#888', textTransform: 'uppercase' as const, letterSpacing: 1 },
memberName: { fontSize: 14, fontWeight: 'bold', marginTop: 4 },
memberNumber: { fontSize: 9, color: '#666', marginTop: 2 },
fieldRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 },
fieldLabel: { fontSize: 7, color: '#888' },
fieldValue: { fontSize: 8 },
footer: { fontSize: 6, color: '#aaa', textAlign: 'center' as const, marginTop: 8 },
});
interface MemberCardData {
firstName: string;
lastName: string;
memberNumber: string;
entryDate: string;
status: string;
}
interface CardPdfProps {
orgName: string;
members: MemberCardData[];
validYear: number;
}
function MemberCardDocument({ orgName, members, validYear }: CardPdfProps) {
return React.createElement(Document, {},
React.createElement(Page, { size: 'A4', style: styles.page },
...members.map((m, i) =>
React.createElement(View, { key: i, style: styles.card },
React.createElement(View, {},
React.createElement(Text, { style: styles.orgName }, orgName),
React.createElement(Text, { style: styles.cardTitle }, 'MITGLIEDSAUSWEIS'),
React.createElement(Text, { style: styles.memberName }, `${m.firstName} ${m.lastName}`),
React.createElement(Text, { style: styles.memberNumber }, `Nr. ${m.memberNumber || '—'}`),
),
React.createElement(View, {},
React.createElement(View, { style: styles.fieldRow },
React.createElement(View, {},
React.createElement(Text, { style: styles.fieldLabel }, 'Mitglied seit'),
React.createElement(Text, { style: styles.fieldValue }, m.entryDate || '—'),
),
React.createElement(View, {},
React.createElement(Text, { style: styles.fieldLabel }, 'Gültig'),
React.createElement(Text, { style: styles.fieldValue }, String(validYear)),
),
),
),
React.createElement(Text, { style: styles.footer }, `${orgName} — Mitgliedsausweis ${validYear}`),
)
)
)
);
}
export async function generateMemberCardsPdf(
orgName: string,
members: MemberCardData[],
validYear?: number,
): Promise<Buffer> {
const year = validYear ?? new Date().getFullYear();
const doc = React.createElement(MemberCardDocument, { orgName, members, validYear: year });
const buffer = await renderToBuffer(doc as any);
return Buffer.from(buffer);
}

View File

@@ -12,13 +12,15 @@
"exports": {
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
"./components": "./src/components/index.ts"
"./components": "./src/components/index.ts",
"./actions/*": "./src/server/actions/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@hookform/resolvers": "catalog:",
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
@@ -29,6 +31,7 @@
"next": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
"react-hook-form": "catalog:",
"zod": "catalog:"
}
}

View File

@@ -0,0 +1,100 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { CreateNewsletterSchema } from '../schema/newsletter.schema';
import { createNewsletter } from '../server/actions/newsletter-actions';
interface Props {
accountId: string;
account: string;
}
export function CreateNewsletterForm({ accountId, account }: Props) {
const router = useRouter();
const form = useForm({
resolver: zodResolver(CreateNewsletterSchema),
defaultValues: {
accountId,
subject: '',
bodyHtml: '',
bodyText: '',
scheduledAt: '',
},
});
const { execute, isPending } = useAction(createNewsletter, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Newsletter erfolgreich erstellt');
router.push(`/home/${account}/newsletter-cms`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Erstellen des Newsletters');
},
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
<Card>
<CardHeader><CardTitle>Newsletter-Inhalt</CardTitle></CardHeader>
<CardContent className="space-y-4">
<FormField control={form.control} name="subject" render={({ field }) => (
<FormItem><FormLabel>Betreff *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="bodyHtml" render={({ field }) => (
<FormItem><FormLabel>Inhalt (HTML) *</FormLabel><FormControl>
<textarea
{...field}
rows={12}
className="flex min-h-[200px] w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm"
placeholder="<h1>Hallo!</h1><p>Ihr Newsletter-Inhalt...</p>"
/>
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="bodyText" render={({ field }) => (
<FormItem><FormLabel>Nur-Text-Version (optional)</FormLabel><FormControl>
<textarea
{...field}
rows={4}
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="Nur-Text-Fallback für E-Mail-Clients ohne HTML-Unterstützung"
/>
</FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Zeitplan</CardTitle></CardHeader>
<CardContent>
<FormField control={form.control} name="scheduledAt" render={({ field }) => (
<FormItem>
<FormLabel>Geplanter Versand (optional)</FormLabel>
<FormControl><Input type="datetime-local" {...field} /></FormControl>
<p className="text-xs text-muted-foreground">
Leer lassen, um den Newsletter als Entwurf zu speichern.
</p>
<FormMessage />
</FormItem>
)} />
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Newsletter erstellen'}</Button>
</div>
</form>
</Form>
);
}

View File

@@ -1 +1 @@
export {};
export { CreateNewsletterForm } from './create-newsletter-form';

View File

@@ -0,0 +1,91 @@
'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
CreateNewsletterSchema,
CreateTemplateSchema,
} from '../../schema/newsletter.schema';
import { createNewsletterApi } from '../api';
export const createNewsletter = authActionClient
.inputSchema(CreateNewsletterSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createNewsletterApi(client);
const userId = ctx.user.id;
logger.info({ name: 'newsletter.create' }, 'Creating newsletter...');
const result = await api.createNewsletter(input, userId);
logger.info({ name: 'newsletter.create' }, 'Newsletter created');
return { success: true, data: result };
});
export const createTemplate = authActionClient
.inputSchema(
z.object({
accountId: z.string().uuid(),
name: z.string().min(1),
subject: z.string().min(1),
bodyHtml: z.string().min(1),
bodyText: z.string().optional(),
variables: z.array(z.string()).optional(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createNewsletterApi(client);
logger.info({ name: 'newsletter.createTemplate' }, 'Creating template...');
const result = await api.createTemplate(input);
logger.info({ name: 'newsletter.createTemplate' }, 'Template created');
return { success: true, data: result };
});
export const addRecipients = authActionClient
.inputSchema(
z.object({
newsletterId: z.string().uuid(),
accountId: z.string().uuid(),
filter: z
.object({
status: z.array(z.string()).optional(),
})
.optional(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createNewsletterApi(client);
logger.info({ name: 'newsletter.addRecipients' }, 'Adding recipients...');
const result = await api.addRecipientsFromMembers(
input.newsletterId,
input.accountId,
input.filter,
);
logger.info({ name: 'newsletter.addRecipients' }, 'Recipients added');
return { success: true, data: result };
});
export const dispatchNewsletter = authActionClient
.inputSchema(
z.object({
newsletterId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createNewsletterApi(client);
logger.info({ name: 'newsletter.dispatch' }, 'Dispatching newsletter...');
const result = await api.dispatch(input.newsletterId);
logger.info({ name: 'newsletter.dispatch' }, 'Newsletter dispatched');
return { success: true, data: result };
});

View File

@@ -0,0 +1,41 @@
{
"name": "@kit/site-builder",
"version": "0.1.0",
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
"./components": "./src/components/index.ts",
"./config/*": "./src/config/*.tsx",
"./actions/*": "./src/server/actions/*.ts",
"./hooks/*": "./src/hooks/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@hookform/resolvers": "catalog:",
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@measured/puck": "*",
"@supabase/supabase-js": "catalog:",
"@types/react": "catalog:",
"lucide-react": "catalog:",
"next": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
"react-hook-form": "catalog:",
"zod": "catalog:"
}
}

View File

@@ -0,0 +1,96 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { CreatePageSchema } from '../schema/site.schema';
import { createPage } from '../server/actions/site-builder-actions';
interface Props {
accountId: string;
account: string;
}
export function CreatePageForm({ accountId, account }: Props) {
const router = useRouter();
const form = useForm({
resolver: zodResolver(CreatePageSchema),
defaultValues: {
accountId,
title: '',
slug: '',
isHomepage: false,
metaDescription: '',
},
});
const watchTitle = form.watch('title');
const autoSlug = watchTitle
.toLowerCase()
.replace(/[^a-z0-9äöüß\s-]+/g, '')
.replace(/\s+/g, '-')
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.replace(/^-|-$/g, '');
const { execute, isPending } = useAction(createPage, {
onSuccess: ({ data }) => {
if (data?.success && data.data) {
toast.success('Seite erstellt — Editor wird geöffnet');
router.push(`/home/${account}/site-builder/${data.data.id}/edit`);
}
},
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler beim Erstellen'),
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute({ ...data, slug: data.slug || autoSlug }))} className="space-y-6 max-w-xl">
<Card>
<CardHeader><CardTitle>Neue Seite erstellen</CardTitle></CardHeader>
<CardContent className="space-y-4">
<FormField control={form.control} name="title" render={({ field }) => (
<FormItem>
<FormLabel>Seitentitel *</FormLabel>
<FormControl><Input placeholder="z.B. Startseite, Über uns, Kontakt" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="slug" render={({ field }) => (
<FormItem>
<FormLabel>URL-Pfad</FormLabel>
<FormControl><Input placeholder={autoSlug || 'wird-automatisch-generiert'} {...field} /></FormControl>
<p className="text-xs text-muted-foreground">Leer lassen für automatische Generierung aus dem Titel</p>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="metaDescription" render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung (SEO)</FormLabel>
<FormControl><Input placeholder="Kurze Beschreibung für Suchmaschinen" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="isHomepage" render={({ field }) => (
<FormItem className="flex items-center gap-3">
<FormControl>
<input type="checkbox" checked={field.value} onChange={field.onChange} className="h-4 w-4" />
</FormControl>
<FormLabel className="!mt-0">Als Startseite festlegen</FormLabel>
</FormItem>
)} />
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Seite erstellen & Editor öffnen'}</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,82 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { CreatePostSchema } from '../schema/site.schema';
import { createPost } from '../server/actions/site-builder-actions';
interface Props {
accountId: string;
account: string;
}
export function CreatePostForm({ accountId, account }: Props) {
const router = useRouter();
const form = useForm({
resolver: zodResolver(CreatePostSchema),
defaultValues: {
accountId,
title: '',
slug: '',
content: '',
excerpt: '',
coverImage: '',
status: 'draft' as const,
},
});
// Auto-generate slug from title
const watchTitle = form.watch('title');
const autoSlug = watchTitle.toLowerCase().replace(/[^a-z0-9äöüß]+/g, '-').replace(/^-|-$/g, '').replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss');
const { execute, isPending } = useAction(createPost, {
onSuccess: () => { toast.success('Beitrag erstellt'); router.push(`/home/${account}/site-builder/posts`); },
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute({ ...data, slug: data.slug || autoSlug }))} className="space-y-6 max-w-3xl">
<Card>
<CardHeader><CardTitle>Beitrag</CardTitle></CardHeader>
<CardContent className="space-y-4">
<FormField control={form.control} name="title" render={({ field }) => (
<FormItem><FormLabel>Titel *</FormLabel><FormControl><Input placeholder="Vereinsnachrichten..." {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="slug" render={({ field }) => (
<FormItem><FormLabel>URL-Slug</FormLabel><FormControl><Input placeholder={autoSlug || 'wird-automatisch-generiert'} {...field} /></FormControl>
<p className="text-xs text-muted-foreground">Leer lassen für automatische Generierung</p><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="excerpt" render={({ field }) => (
<FormItem><FormLabel>Kurzfassung</FormLabel><FormControl><Input placeholder="Kurze Zusammenfassung..." {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="content" render={({ field }) => (
<FormItem><FormLabel>Inhalt</FormLabel><FormControl>
<textarea {...field} rows={12} className="w-full rounded-md border px-3 py-2 text-sm font-mono" placeholder="Beitragsinhalt (HTML erlaubt)..." />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="status" render={({ field }) => (
<FormItem><FormLabel>Status</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="draft">Entwurf</option>
<option value="published">Veröffentlicht</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Beitrag erstellen'}</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,6 @@
export { SiteRenderer } from './site-renderer';
export { SiteEditor } from './site-editor';
export { SiteSettingsForm } from './site-settings-form';
export { CreatePostForm } from './create-post-form';
export { CreatePageForm } from './create-page-form';
export { PortalLoginForm } from './portal-login-form';

View File

@@ -0,0 +1,113 @@
'use client';
import { useState } from 'react';
import { createClient } from '@supabase/supabase-js';
import { useRouter } from 'next/navigation';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { Shield, LogIn, AlertCircle } from 'lucide-react';
interface Props {
slug: string;
accountName: string;
}
export function PortalLoginForm({ slug, accountName }: Props) {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
);
const { data, error: authError } = await supabase.auth.signInWithPassword({
email,
password,
});
if (authError) {
setError('Ungültige Anmeldedaten. Bitte überprüfen Sie E-Mail und Passwort.');
setLoading(false);
return;
}
if (data.user) {
toast.success('Erfolgreich angemeldet');
router.push(`/club/${slug}/portal/profile`);
router.refresh();
}
} catch (err) {
setError('Verbindungsfehler. Bitte versuchen Sie es erneut.');
} finally {
setLoading(false);
}
};
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Shield className="h-6 w-6 text-primary" />
</div>
<CardTitle>Mitgliederbereich</CardTitle>
<p className="text-sm text-muted-foreground">{accountName}</p>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<div className="space-y-2">
<Label>E-Mail-Adresse</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ihre@email.de"
required
/>
</div>
<div className="space-y-2">
<Label>Passwort</Label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
'Wird angemeldet...'
) : (
<>
<LogIn className="mr-2 h-4 w-4" />
Anmelden
</>
)}
</Button>
<p className="text-xs text-center text-muted-foreground">
Zugangsdaten erhalten Sie per Einladung von Ihrem Verein.
</p>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,35 @@
'use client';
import { Puck } from '@measured/puck';
import '@measured/puck/puck.css';
import { useAction } from 'next-safe-action/hooks';
import { toast } from '@kit/ui/sonner';
import { clubPuckConfig } from '../config/puck-config';
import { publishPage } from '../server/actions/site-builder-actions';
interface Props {
pageId: string;
accountId: string;
initialData: Record<string, unknown>;
}
export function SiteEditor({ pageId, accountId, initialData }: Props) {
const { execute: execPublish } = useAction(publishPage, {
onSuccess: () => toast.success('Seite veröffentlicht'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
const PuckAny = Puck as any;
return (
<div className="h-screen">
<PuckAny
config={clubPuckConfig}
data={initialData}
onPublish={async (data: any) => {
execPublish({ pageId, puckData: data });
}}
/>
</div>
);
}

View File

@@ -0,0 +1,12 @@
'use client';
import { Render } from '@measured/puck';
import { clubPuckConfig } from '../config/puck-config';
interface Props {
data: Record<string, unknown>;
}
export function SiteRenderer({ data }: Props) {
return <Render config={clubPuckConfig} data={data as any} />;
}

View File

@@ -0,0 +1,121 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { SiteSettingsSchema } from '../schema/site.schema';
import { updateSiteSettings } from '../server/actions/site-builder-actions';
interface Props {
accountId: string;
account: string;
settings: Record<string, unknown> | null;
}
export function SiteSettingsForm({ accountId, account, settings }: Props) {
const router = useRouter();
const form = useForm({
resolver: zodResolver(SiteSettingsSchema),
defaultValues: {
accountId,
siteName: String(settings?.site_name ?? ''),
siteLogo: String(settings?.site_logo ?? ''),
primaryColor: String(settings?.primary_color ?? '#2563eb'),
secondaryColor: String(settings?.secondary_color ?? '#64748b'),
fontFamily: String(settings?.font_family ?? 'Inter'),
contactEmail: String(settings?.contact_email ?? ''),
contactPhone: String(settings?.contact_phone ?? ''),
contactAddress: String(settings?.contact_address ?? ''),
footerText: String(settings?.footer_text ?? ''),
impressum: String(settings?.impressum ?? ''),
datenschutz: String(settings?.datenschutz ?? ''),
isPublic: Boolean(settings?.is_public),
navigation: [] as Array<{ label: string; href: string }>,
},
});
const { execute, isPending } = useAction(updateSiteSettings, {
onSuccess: () => { toast.success('Einstellungen gespeichert'); router.refresh(); },
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6 max-w-3xl">
<Card>
<CardHeader><CardTitle>Allgemein</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="siteName" render={({ field }) => (
<FormItem><FormLabel>Website-Name</FormLabel><FormControl><Input placeholder="Mein Verein" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="fontFamily" render={({ field }) => (
<FormItem><FormLabel>Schriftart</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="Inter">Inter</option>
<option value="system-ui">System</option>
<option value="Georgia">Georgia</option>
<option value="Roboto">Roboto</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="primaryColor" render={({ field }) => (
<FormItem><FormLabel>Primärfarbe</FormLabel><FormControl><Input type="color" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="secondaryColor" render={({ field }) => (
<FormItem><FormLabel>Sekundärfarbe</FormLabel><FormControl><Input type="color" {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Kontakt</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="contactEmail" render={({ field }) => (
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="contactPhone" render={({ field }) => (
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="contactAddress" render={({ field }) => (
<FormItem className="col-span-full"><FormLabel>Adresse</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Rechtliches</CardTitle></CardHeader>
<CardContent className="space-y-4">
<FormField control={form.control} name="impressum" render={({ field }) => (
<FormItem><FormLabel>Impressum</FormLabel><FormControl><textarea {...field} rows={6} className="w-full rounded-md border px-3 py-2 text-sm" /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="datenschutz" render={({ field }) => (
<FormItem><FormLabel>Datenschutzerklärung</FormLabel><FormControl><textarea {...field} rows={6} className="w-full rounded-md border px-3 py-2 text-sm" /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Veröffentlichung</CardTitle></CardHeader>
<CardContent>
<FormField control={form.control} name="isPublic" render={({ field }) => (
<FormItem className="flex items-center gap-3">
<FormControl><input type="checkbox" checked={field.value} onChange={field.onChange} className="h-4 w-4" /></FormControl>
<div>
<FormLabel>Website öffentlich zugänglich</FormLabel>
<p className="text-xs text-muted-foreground">Wenn aktiviert, ist Ihre Website unter /club/{account} erreichbar.</p>
</div>
</FormItem>
)} />
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird gespeichert...' : 'Einstellungen speichern'}</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,360 @@
import type { Config } from '@measured/puck';
import React from 'react';
// Block components inline for simplicity
const HeroBlock = ({ title, subtitle, buttonText, buttonLink }: { title: string; subtitle: string; buttonText: string; buttonLink: string }) => (
<section className="relative bg-gradient-to-br from-primary/10 to-primary/5 py-20 px-6 text-center">
<h1 className="text-4xl font-bold md:text-5xl">{title || 'Willkommen'}</h1>
{subtitle && <p className="mt-4 text-lg text-muted-foreground max-w-2xl mx-auto">{subtitle}</p>}
{buttonText && (
<a href={buttonLink || '#'} className="mt-8 inline-block rounded-md bg-primary px-6 py-3 text-sm font-semibold text-primary-foreground hover:bg-primary/90">
{buttonText}
</a>
)}
</section>
);
const TextBlock = ({ content }: { content: string }) => (
<section className="py-12 px-6 max-w-3xl mx-auto prose prose-neutral dark:prose-invert" dangerouslySetInnerHTML={{ __html: content || '<p>Text eingeben...</p>' }} />
);
const ContactFormBlock = ({ title, description, recipientEmail }: { title: string; description: string; recipientEmail: string }) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const data = new FormData(form);
try {
const res = await fetch('/api/club/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipientEmail: recipientEmail || '',
name: data.get('name'),
email: data.get('email'),
subject: data.get('subject') || 'Kontaktanfrage',
message: data.get('message'),
}),
});
const result = await res.json();
if (result.success) {
alert('Nachricht erfolgreich gesendet!');
form.reset();
} else {
alert(result.error || 'Fehler beim Senden');
}
} catch { alert('Verbindungsfehler'); }
};
return (
<section className="py-12 px-6 max-w-2xl mx-auto">
<h2 className="text-2xl font-bold mb-2">{title || 'Kontakt'}</h2>
{description && <p className="text-muted-foreground mb-6">{description}</p>}
<form onSubmit={handleSubmit} className="space-y-4">
<input name="name" placeholder="Name" className="w-full rounded-md border px-3 py-2 text-sm" required />
<input name="email" placeholder="E-Mail" type="email" className="w-full rounded-md border px-3 py-2 text-sm" required />
<textarea name="message" placeholder="Nachricht" rows={4} className="w-full rounded-md border px-3 py-2 text-sm" required />
<button type="submit" className="rounded-md bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground hover:bg-primary/90">Senden</button>
</form>
</section>
);
};
const MapBlock = ({ latitude, longitude, zoom, height }: { latitude: number; longitude: number; zoom: number; height: number }) => (
<section className="py-6 px-6">
<iframe
src={`https://www.openstreetmap.org/export/embed.html?bbox=${longitude-0.01},${latitude-0.01},${longitude+0.01},${latitude+0.01}&layer=mapnik&marker=${latitude},${longitude}`}
style={{ width: '100%', height: height || 400, border: 0, borderRadius: '0.5rem' }}
title="Karte"
/>
</section>
);
const ImageGalleryBlock = ({ images, columns }: { images: Array<{ url: string; alt: string }>; columns: number }) => (
<section className="py-12 px-6">
<div className={`grid gap-4 grid-cols-1 sm:grid-cols-${columns || 3}`} style={{ gridTemplateColumns: `repeat(${columns || 3}, 1fr)` }}>
{(images || []).map((img, i) => (
<img key={i} src={img.url} alt={img.alt || ''} className="rounded-lg object-cover w-full aspect-square" />
))}
</div>
</section>
);
const DividerBlock = ({ style, spacing }: { style: string; spacing: string }) => {
const py = spacing === 'lg' ? 'py-12' : spacing === 'sm' ? 'py-3' : 'py-6';
return (
<div className={`${py} px-6`}>
{style === 'dots' ? (
<div className="flex justify-center gap-2">{[0,1,2].map(i => <span key={i} className="h-2 w-2 rounded-full bg-muted-foreground/30" />)}</div>
) : style === 'space' ? null : (
<hr className="border-border" />
)}
</div>
);
};
const NewsletterSignupBlock = ({ title, description, accountId }: { title: string; description: string; accountId?: string }) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const data = new FormData(form);
try {
const res = await fetch('/api/club/newsletter', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accountId: accountId || '',
email: data.get('email'),
name: data.get('name') || '',
}),
});
const result = await res.json();
if (result.success) {
alert('Erfolgreich angemeldet! Bitte bestätigen Sie Ihre E-Mail.');
form.reset();
} else {
alert(result.error || 'Fehler bei der Anmeldung');
}
} catch { alert('Verbindungsfehler'); }
};
return (
<section className="py-12 px-6 bg-muted/50">
<div className="max-w-md mx-auto text-center">
<h2 className="text-2xl font-bold">{title || 'Newsletter'}</h2>
{description && <p className="mt-2 text-muted-foreground">{description}</p>}
<form onSubmit={handleSubmit} className="mt-6 flex gap-2">
<input name="email" placeholder="Ihre E-Mail-Adresse" type="email" required className="flex-1 rounded-md border px-3 py-2 text-sm" />
<button type="submit" className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground hover:bg-primary/90">Anmelden</button>
</form>
</div>
</section>
);
};
const DownloadBlock = ({ title, files }: { title: string; files: Array<{ label: string; url: string }> }) => (
<section className="py-12 px-6 max-w-2xl mx-auto">
<h2 className="text-2xl font-bold mb-4">{title || 'Downloads'}</h2>
<ul className="space-y-2">
{(files || []).map((file, i) => (
<li key={i}>
<a href={file.url} download className="flex items-center gap-2 rounded-md border p-3 hover:bg-muted/50 text-sm">
📄 {file.label}
</a>
</li>
))}
</ul>
</section>
);
const FooterBlock = ({ text, email, phone }: { text: string; email: string; phone: string }) => (
<footer className="bg-muted py-8 px-6 text-center text-sm text-muted-foreground">
{text && <p>{text}</p>}
<div className="mt-2 flex justify-center gap-4">
{email && <a href={`mailto:${email}`}>{email}</a>}
{phone && <a href={`tel:${phone}`}>{phone}</a>}
</div>
</footer>
);
const MemberLoginBlock = ({ title, description }: { title: string; description: string }) => (
<section className="py-12 px-6 text-center">
<h2 className="text-2xl font-bold">{title || 'Mitgliederbereich'}</h2>
{description && <p className="mt-2 text-muted-foreground">{description}</p>}
<a href="portal" className="mt-6 inline-block rounded-md bg-primary px-6 py-3 text-sm font-semibold text-primary-foreground">Zum Mitgliederbereich </a>
</section>
);
const NewsFeedBlock = ({ count, showImage }: { count: number; showImage: boolean }) => (
<section className="py-12 px-6 max-w-4xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Neuigkeiten</h2>
<div className="space-y-4">
{Array.from({ length: count || 3 }, (_, i) => (
<div key={i} className="rounded-lg border p-4 hover:bg-muted/30 transition-colors">
<div className="flex gap-4">
{showImage && <div className="h-20 w-20 shrink-0 rounded bg-muted" />}
<div>
<h3 className="font-semibold">Beitragstitel {i + 1}</h3>
<p className="text-sm text-muted-foreground mt-1">Kurzbeschreibung des Beitrags...</p>
<p className="text-xs text-muted-foreground mt-2">01.01.2026</p>
</div>
</div>
</div>
))}
</div>
</section>
);
const EventListBlock = ({ count, showPastEvents }: { count: number; showPastEvents: boolean }) => (
<section className="py-12 px-6 max-w-4xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Veranstaltungen</h2>
<div className="space-y-3">
{Array.from({ length: count || 3 }, (_, i) => (
<div key={i} className="flex items-center gap-4 rounded-lg border p-4">
<div className="flex h-14 w-14 shrink-0 flex-col items-center justify-center rounded-lg bg-primary/10 text-primary">
<span className="text-lg font-bold">{15 + i}</span>
<span className="text-xs">Apr</span>
</div>
<div>
<h3 className="font-semibold">Veranstaltung {i + 1}</h3>
<p className="text-xs text-muted-foreground">10:00 Vereinsheim</p>
</div>
</div>
))}
</div>
</section>
);
const CourseCatalogBlock = ({ count, showPrice }: { count: number; showPrice: boolean }) => (
<section className="py-12 px-6 max-w-4xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Kursangebot</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{Array.from({ length: count || 4 }, (_, i) => (
<div key={i} className="rounded-lg border p-4">
<h3 className="font-semibold">Kurs {i + 1}</h3>
<p className="text-sm text-muted-foreground mt-1">Mo, 18:00 20:00</p>
<div className="mt-3 flex items-center justify-between">
{showPrice && <span className="text-sm font-semibold text-primary">49,00 </span>}
<span className="text-xs text-muted-foreground">5/15 Plätze</span>
</div>
</div>
))}
</div>
</section>
);
const CardShopBlock = ({ title, description }: { title: string; description: string }) => (
<section className="py-12 px-6 max-w-4xl mx-auto">
<h2 className="text-2xl font-bold mb-2">{title || 'Mitgliedschaft'}</h2>
{description && <p className="text-muted-foreground mb-6">{description}</p>}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{['Basis', 'Standard', 'Familie'].map((name, i) => (
<div key={name} className="rounded-lg border p-6 text-center hover:border-primary transition-colors">
<h3 className="text-lg font-bold">{name}</h3>
<p className="text-3xl font-bold text-primary mt-2">{[5, 10, 18][i]} </p>
<p className="text-xs text-muted-foreground">pro Monat</p>
<button className="mt-4 w-full rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground">Auswählen</button>
</div>
))}
</div>
</section>
);
const ColumnsBlock = ({ columns }: { columns: number }) => (
<section className="py-8 px-6">
<div className="grid gap-6" style={{ gridTemplateColumns: `repeat(${columns || 2}, 1fr)` }}>
{Array.from({ length: columns || 2 }, (_, i) => (
<div key={i} className="min-h-[100px] rounded-lg border-2 border-dashed border-muted-foreground/20 p-4 flex items-center justify-center text-sm text-muted-foreground">
Spalte {i + 1}
</div>
))}
</div>
</section>
);
export const clubPuckConfig: Config = {
categories: {
layout: { title: 'Layout', components: ['Columns', 'Divider'] },
content: { title: 'Inhalt', components: ['Hero', 'Text', 'ImageGallery'] },
club: { title: 'Verein', components: ['NewsFeed', 'EventList', 'MemberLogin', 'CardShop', 'CourseCatalog'] },
communication: { title: 'Kommunikation', components: ['ContactForm', 'NewsletterSignup', 'Download'] },
embed: { title: 'Einbetten', components: ['Map'] },
navigation: { title: 'Navigation', components: ['Footer'] },
},
components: {
Hero: {
fields: {
title: { type: 'text' },
subtitle: { type: 'textarea' },
buttonText: { type: 'text' },
buttonLink: { type: 'text' },
},
defaultProps: { title: 'Willkommen bei unserem Verein', subtitle: '', buttonText: '', buttonLink: '' },
render: HeroBlock as any,
},
Text: {
fields: { content: { type: 'textarea' } },
defaultProps: { content: '<p>Hier steht Ihr Text...</p>' },
render: TextBlock as any,
},
ContactForm: {
fields: { title: { type: 'text' }, description: { type: 'textarea' }, recipientEmail: { type: 'text' } },
defaultProps: { title: 'Kontakt', description: 'Schreiben Sie uns eine Nachricht.', recipientEmail: '' },
render: ContactFormBlock as any,
},
Map: {
fields: {
latitude: { type: 'number' },
longitude: { type: 'number' },
zoom: { type: 'number' },
height: { type: 'number' },
},
defaultProps: { latitude: 48.1351, longitude: 11.5820, zoom: 15, height: 400 },
render: MapBlock as any,
},
ImageGallery: {
fields: {
images: { type: 'array', arrayFields: { url: { type: 'text' }, alt: { type: 'text' } } } as any,
columns: { type: 'number' },
},
defaultProps: { images: [], columns: 3 },
render: ImageGalleryBlock as any,
},
Divider: {
fields: {
style: { type: 'select', options: [{ label: 'Linie', value: 'line' }, { label: 'Punkte', value: 'dots' }, { label: 'Abstand', value: 'space' }] },
spacing: { type: 'select', options: [{ label: 'Klein', value: 'sm' }, { label: 'Mittel', value: 'md' }, { label: 'Groß', value: 'lg' }] },
},
defaultProps: { style: 'line', spacing: 'md' },
render: DividerBlock as any,
},
NewsletterSignup: {
fields: { title: { type: 'text' }, description: { type: 'textarea' } },
defaultProps: { title: 'Newsletter abonnieren', description: 'Bleiben Sie auf dem Laufenden.' },
render: NewsletterSignupBlock as any,
},
Download: {
fields: {
title: { type: 'text' },
files: { type: 'array', arrayFields: { label: { type: 'text' }, url: { type: 'text' } } } as any,
},
defaultProps: { title: 'Downloads', files: [] },
render: DownloadBlock as any,
},
Footer: {
fields: { text: { type: 'text' }, email: { type: 'text' }, phone: { type: 'text' } },
defaultProps: { text: '© 2026 Unser Verein', email: '', phone: '' },
render: FooterBlock as any,
},
MemberLogin: {
fields: { title: { type: 'text' }, description: { type: 'textarea' } },
defaultProps: { title: 'Mitgliederbereich', description: 'Melden Sie sich an, um auf Ihren persönlichen Bereich zuzugreifen.' },
render: MemberLoginBlock as any,
},
NewsFeed: {
fields: { count: { type: 'number' }, showImage: { type: 'radio', options: [{ label: 'Ja', value: true }, { label: 'Nein', value: false }] } },
defaultProps: { count: 5, showImage: true },
render: NewsFeedBlock as any,
},
EventList: {
fields: { count: { type: 'number' }, showPastEvents: { type: 'radio', options: [{ label: 'Ja', value: true }, { label: 'Nein', value: false }] } },
defaultProps: { count: 5, showPastEvents: false },
render: EventListBlock as any,
},
CourseCatalog: {
fields: { count: { type: 'number' }, showPrice: { type: 'radio', options: [{ label: 'Ja', value: true }, { label: 'Nein', value: false }] } },
defaultProps: { count: 4, showPrice: true },
render: CourseCatalogBlock as any,
},
CardShop: {
fields: { title: { type: 'text' }, description: { type: 'textarea' } },
defaultProps: { title: 'Mitgliedschaft', description: 'Werden Sie Mitglied in unserem Verein.' },
render: CardShopBlock as any,
},
Columns: {
fields: { columns: { type: 'number' } },
defaultProps: { columns: 2 },
render: ColumnsBlock as any,
},
},
};

View File

@@ -0,0 +1,66 @@
import { z } from 'zod';
export const CreatePageSchema = z.object({
accountId: z.string().uuid(),
slug: z.string().min(1).max(128).regex(/^[a-z0-9-]+$/),
title: z.string().min(1).max(256),
puckData: z.record(z.string(), z.unknown()).default({}),
isHomepage: z.boolean().default(false),
metaDescription: z.string().optional(),
});
export type CreatePageInput = z.infer<typeof CreatePageSchema>;
export const UpdatePageSchema = z.object({
pageId: z.string().uuid(),
title: z.string().optional(),
slug: z.string().optional(),
puckData: z.record(z.string(), z.unknown()).optional(),
isPublished: z.boolean().optional(),
isHomepage: z.boolean().optional(),
metaDescription: z.string().optional(),
metaImage: z.string().optional(),
});
export const SiteSettingsSchema = z.object({
accountId: z.string().uuid(),
siteName: z.string().optional(),
siteLogo: z.string().optional(),
primaryColor: z.string().default('#2563eb'),
secondaryColor: z.string().default('#64748b'),
fontFamily: z.string().default('Inter'),
customCss: z.string().optional(),
navigation: z.array(z.object({ label: z.string(), href: z.string() })).default([]),
footerText: z.string().optional(),
contactEmail: z.string().optional(),
contactPhone: z.string().optional(),
contactAddress: z.string().optional(),
impressum: z.string().optional(),
datenschutz: z.string().optional(),
isPublic: z.boolean().default(false),
});
export const CreatePostSchema = z.object({
accountId: z.string().uuid(),
title: z.string().min(1),
slug: z.string().min(1).regex(/^[a-z0-9-]+$/),
content: z.string().optional(),
excerpt: z.string().optional(),
coverImage: z.string().optional(),
status: z.enum(['draft', 'published', 'archived']).default('draft'),
});
export const UpdatePostSchema = z.object({
postId: z.string().uuid(),
title: z.string().optional(),
slug: z.string().optional(),
content: z.string().optional(),
excerpt: z.string().optional(),
coverImage: z.string().optional(),
status: z.enum(['draft', 'published', 'archived']).optional(),
});
export const NewsletterSubscribeSchema = z.object({
accountId: z.string().uuid(),
email: z.string().email(),
name: z.string().optional(),
});

View File

@@ -0,0 +1,80 @@
'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreatePageSchema, UpdatePageSchema, SiteSettingsSchema, CreatePostSchema, UpdatePostSchema, NewsletterSubscribeSchema } from '../../schema/site.schema';
import { createSiteBuilderApi } from '../api';
export const createPage = authActionClient
.inputSchema(CreatePageSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const api = createSiteBuilderApi(client);
const data = await api.createPage(input, ctx.user.id);
return { success: true, data };
});
export const saveDraft = authActionClient
.inputSchema(UpdatePageSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const api = createSiteBuilderApi(client);
const data = await api.updatePage(input.pageId, { ...input, isPublished: false }, ctx.user.id);
return { success: true, data };
});
export const publishPage = authActionClient
.inputSchema(UpdatePageSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const api = createSiteBuilderApi(client);
const data = await api.updatePage(input.pageId, { ...input, isPublished: true }, ctx.user.id);
return { success: true, data };
});
export const deletePage = authActionClient
.inputSchema(z.object({ pageId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createSiteBuilderApi(client);
await api.deletePage(input.pageId);
return { success: true };
});
export const updateSiteSettings = authActionClient
.inputSchema(SiteSettingsSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createSiteBuilderApi(client);
const data = await api.upsertSiteSettings(input.accountId, input);
return { success: true, data };
});
export const createPost = authActionClient
.inputSchema(CreatePostSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const api = createSiteBuilderApi(client);
const data = await api.createPost(input, ctx.user.id);
return { success: true, data };
});
export const updatePost = authActionClient
.inputSchema(UpdatePostSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createSiteBuilderApi(client);
const data = await api.updatePost(input.postId, input);
return { success: true, data };
});
export const deletePost = authActionClient
.inputSchema(z.object({ postId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createSiteBuilderApi(client);
await api.deletePost(input.postId);
return { success: true };
});

View File

@@ -0,0 +1,136 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createSiteBuilderApi(client: SupabaseClient<Database>) {
return {
// Pages
async listPages(accountId: string) {
const { data, error } = await client.from('site_pages').select('*')
.eq('account_id', accountId).order('sort_order');
if (error) throw error;
return data ?? [];
},
async getPage(pageId: string) {
const { data, error } = await client.from('site_pages').select('*').eq('id', pageId).single();
if (error) throw error;
return data;
},
async getPageBySlug(accountId: string, slug: string) {
const { data, error } = await client.from('site_pages').select('*')
.eq('account_id', accountId).eq('slug', slug).single();
if (error) throw error;
return data;
},
async getHomepage(accountId: string) {
const { data, error } = await client.from('site_pages').select('*')
.eq('account_id', accountId).eq('is_homepage', true).eq('is_published', true).maybeSingle();
if (error) throw error;
return data;
},
async createPage(input: { accountId: string; slug: string; title: string; puckData?: Record<string, unknown>; isHomepage?: boolean; metaDescription?: string }, userId: string) {
const { data, error } = await client.from('site_pages').insert({
account_id: input.accountId, slug: input.slug, title: input.title,
puck_data: (input.puckData ?? {}) as any, is_homepage: input.isHomepage ?? false,
meta_description: input.metaDescription, created_by: userId, updated_by: userId,
}).select().single();
if (error) throw error;
return data;
},
async updatePage(pageId: string, input: { title?: string; slug?: string; puckData?: Record<string, unknown>; isPublished?: boolean; isHomepage?: boolean; metaDescription?: string; metaImage?: string }, userId: string) {
const update: Record<string, unknown> = { updated_by: userId };
if (input.title !== undefined) update.title = input.title;
if (input.slug !== undefined) update.slug = input.slug;
if (input.puckData !== undefined) update.puck_data = input.puckData;
if (input.isPublished !== undefined) {
update.is_published = input.isPublished;
if (input.isPublished) update.published_at = new Date().toISOString();
}
if (input.isHomepage !== undefined) update.is_homepage = input.isHomepage;
if (input.metaDescription !== undefined) update.meta_description = input.metaDescription;
if (input.metaImage !== undefined) update.meta_image = input.metaImage;
const { data, error } = await client.from('site_pages').update(update).eq('id', pageId).select().single();
if (error) throw error;
return data;
},
async deletePage(pageId: string) {
const { error } = await client.from('site_pages').delete().eq('id', pageId);
if (error) throw error;
},
// Settings
async getSiteSettings(accountId: string) {
const { data, error } = await client.from('site_settings').select('*').eq('account_id', accountId).maybeSingle();
if (error) throw error;
return data;
},
async upsertSiteSettings(accountId: string, input: Record<string, unknown>) {
const row: Record<string, unknown> = { account_id: accountId };
if (input.siteName !== undefined) row.site_name = input.siteName;
if (input.siteLogo !== undefined) row.site_logo = input.siteLogo;
if (input.primaryColor !== undefined) row.primary_color = input.primaryColor;
if (input.secondaryColor !== undefined) row.secondary_color = input.secondaryColor;
if (input.fontFamily !== undefined) row.font_family = input.fontFamily;
if (input.navigation !== undefined) row.navigation = input.navigation;
if (input.footerText !== undefined) row.footer_text = input.footerText;
if (input.contactEmail !== undefined) row.contact_email = input.contactEmail;
if (input.contactPhone !== undefined) row.contact_phone = input.contactPhone;
if (input.contactAddress !== undefined) row.contact_address = input.contactAddress;
if (input.impressum !== undefined) row.impressum = input.impressum;
if (input.datenschutz !== undefined) row.datenschutz = input.datenschutz;
if (input.isPublic !== undefined) row.is_public = input.isPublic;
const { data, error } = await client.from('site_settings').upsert(row as any).select().single();
if (error) throw error;
return data;
},
// Posts
async listPosts(accountId: string, status?: string) {
let query = client.from('cms_posts').select('*').eq('account_id', accountId).order('created_at', { ascending: false });
if (status) query = query.eq('status', status);
const { data, error } = await query;
if (error) throw error;
return data ?? [];
},
async getPost(postId: string) {
const { data, error } = await client.from('cms_posts').select('*').eq('id', postId).single();
if (error) throw error;
return data;
},
async createPost(input: { accountId: string; title: string; slug: string; content?: string; excerpt?: string; coverImage?: string; status?: string }, userId: string) {
const { data, error } = await client.from('cms_posts').insert({
account_id: input.accountId, title: input.title, slug: input.slug,
content: input.content, excerpt: input.excerpt, cover_image: input.coverImage,
status: input.status ?? 'draft', author_id: userId,
published_at: input.status === 'published' ? new Date().toISOString() : null,
}).select().single();
if (error) throw error;
return data;
},
async updatePost(postId: string, input: { title?: string; slug?: string; content?: string; excerpt?: string; coverImage?: string; status?: string }) {
const update: Record<string, unknown> = {};
if (input.title !== undefined) update.title = input.title;
if (input.slug !== undefined) update.slug = input.slug;
if (input.content !== undefined) update.content = input.content;
if (input.excerpt !== undefined) update.excerpt = input.excerpt;
if (input.coverImage !== undefined) update.cover_image = input.coverImage;
if (input.status !== undefined) {
update.status = input.status;
if (input.status === 'published') update.published_at = new Date().toISOString();
}
const { data, error } = await client.from('cms_posts').update(update).eq('id', postId).select().single();
if (error) throw error;
return data;
},
async deletePost(postId: string) {
const { error } = await client.from('cms_posts').delete().eq('id', postId);
if (error) throw error;
},
// Newsletter
async subscribe(accountId: string, email: string, name?: string) {
const token = crypto.randomUUID();
const { error } = await client.from('newsletter_subscriptions').upsert({
account_id: accountId, email, name, confirmation_token: token, is_active: true,
}, { onConflict: 'account_id,email' });
if (error) throw error;
return token;
},
};
}

View File

@@ -0,0 +1,6 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
"include": ["*.ts", "*.tsx", "src"],
"exclude": ["node_modules"]
}

View File

@@ -496,6 +496,73 @@ export type Database = {
},
]
}
cms_posts: {
Row: {
account_id: string
author_id: string | null
content: string | null
cover_image: string | null
created_at: string
excerpt: string | null
id: string
published_at: string | null
slug: string
status: string
title: string
updated_at: string
}
Insert: {
account_id: string
author_id?: string | null
content?: string | null
cover_image?: string | null
created_at?: string
excerpt?: string | null
id?: string
published_at?: string | null
slug: string
status?: string
title: string
updated_at?: string
}
Update: {
account_id?: string
author_id?: string | null
content?: string | null
cover_image?: string | null
created_at?: string
excerpt?: string | null
id?: string
published_at?: string | null
slug?: string
status?: string
title?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "cms_posts_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "accounts"
referencedColumns: ["id"]
},
{
foreignKeyName: "cms_posts_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_account_workspace"
referencedColumns: ["id"]
},
{
foreignKeyName: "cms_posts_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_accounts"
referencedColumns: ["id"]
},
]
}
config: {
Row: {
billing_provider: Database["public"]["Enums"]["billing_provider"]
@@ -963,6 +1030,8 @@ export type Database = {
id: string
interval: string
is_default: boolean
is_exit: boolean
is_youth: boolean
name: string
sort_order: number
}
@@ -974,6 +1043,8 @@ export type Database = {
id?: string
interval?: string
is_default?: boolean
is_exit?: boolean
is_youth?: boolean
name: string
sort_order?: number
}
@@ -985,6 +1056,8 @@ export type Database = {
id?: string
interval?: string
is_default?: boolean
is_exit?: boolean
is_youth?: boolean
name?: string
sort_order?: number
}
@@ -1671,126 +1744,499 @@ export type Database = {
},
]
}
member_department_assignments: {
Row: {
department_id: string
member_id: string
}
Insert: {
department_id: string
member_id: string
}
Update: {
department_id?: string
member_id?: string
}
Relationships: [
{
foreignKeyName: "member_department_assignments_department_id_fkey"
columns: ["department_id"]
isOneToOne: false
referencedRelation: "member_departments"
referencedColumns: ["id"]
},
{
foreignKeyName: "member_department_assignments_member_id_fkey"
columns: ["member_id"]
isOneToOne: false
referencedRelation: "members"
referencedColumns: ["id"]
},
]
}
member_departments: {
Row: {
account_id: string
created_at: string
description: string | null
id: string
name: string
sort_order: number
}
Insert: {
account_id: string
created_at?: string
description?: string | null
id?: string
name: string
sort_order?: number
}
Update: {
account_id?: string
created_at?: string
description?: string | null
id?: string
name?: string
sort_order?: number
}
Relationships: [
{
foreignKeyName: "member_departments_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "accounts"
referencedColumns: ["id"]
},
{
foreignKeyName: "member_departments_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_account_workspace"
referencedColumns: ["id"]
},
{
foreignKeyName: "member_departments_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_accounts"
referencedColumns: ["id"]
},
]
}
member_honors: {
Row: {
account_id: string
created_at: string
description: string | null
honor_date: string | null
honor_name: string
id: string
member_id: string
}
Insert: {
account_id: string
created_at?: string
description?: string | null
honor_date?: string | null
honor_name: string
id?: string
member_id: string
}
Update: {
account_id?: string
created_at?: string
description?: string | null
honor_date?: string | null
honor_name?: string
id?: string
member_id?: string
}
Relationships: [
{
foreignKeyName: "member_honors_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "accounts"
referencedColumns: ["id"]
},
{
foreignKeyName: "member_honors_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_account_workspace"
referencedColumns: ["id"]
},
{
foreignKeyName: "member_honors_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_accounts"
referencedColumns: ["id"]
},
{
foreignKeyName: "member_honors_member_id_fkey"
columns: ["member_id"]
isOneToOne: false
referencedRelation: "members"
referencedColumns: ["id"]
},
]
}
member_portal_invitations: {
Row: {
accepted_at: string | null
account_id: string
created_at: string
email: string
expires_at: string
id: string
invite_token: string
invited_by: string | null
member_id: string
status: string
}
Insert: {
accepted_at?: string | null
account_id: string
created_at?: string
email: string
expires_at?: string
id?: string
invite_token?: string
invited_by?: string | null
member_id: string
status?: string
}
Update: {
accepted_at?: string | null
account_id?: string
created_at?: string
email?: string
expires_at?: string
id?: string
invite_token?: string
invited_by?: string | null
member_id?: string
status?: string
}
Relationships: [
{
foreignKeyName: "member_portal_invitations_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "accounts"
referencedColumns: ["id"]
},
{
foreignKeyName: "member_portal_invitations_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_account_workspace"
referencedColumns: ["id"]
},
{
foreignKeyName: "member_portal_invitations_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_accounts"
referencedColumns: ["id"]
},
{
foreignKeyName: "member_portal_invitations_member_id_fkey"
columns: ["member_id"]
isOneToOne: false
referencedRelation: "members"
referencedColumns: ["id"]
},
]
}
member_roles: {
Row: {
account_id: string
created_at: string
from_date: string | null
id: string
is_active: boolean
member_id: string
role_name: string
until_date: string | null
}
Insert: {
account_id: string
created_at?: string
from_date?: string | null
id?: string
is_active?: boolean
member_id: string
role_name: string
until_date?: string | null
}
Update: {
account_id?: string
created_at?: string
from_date?: string | null
id?: string
is_active?: boolean
member_id?: string
role_name?: string
until_date?: string | null
}
Relationships: [
{
foreignKeyName: "member_roles_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "accounts"
referencedColumns: ["id"]
},
{
foreignKeyName: "member_roles_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_account_workspace"
referencedColumns: ["id"]
},
{
foreignKeyName: "member_roles_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_accounts"
referencedColumns: ["id"]
},
{
foreignKeyName: "member_roles_member_id_fkey"
columns: ["member_id"]
isOneToOne: false
referencedRelation: "members"
referencedColumns: ["id"]
},
]
}
members: {
Row: {
account_holder: string | null
account_id: string
additional_fees: number | null
address_invalid: boolean
bic: string | null
birth_country: string | null
birthplace: string | null
city: string | null
country: string | null
created_at: string
created_by: string | null
custom_data: Json
data_reconciliation_needed: boolean
date_of_birth: string | null
dues_category_id: string | null
dues_paid: boolean
dues_year: number | null
email: string | null
email_confirmed: boolean
entry_date: string
exemption_amount: number | null
exemption_reason: string | null
exemption_type: string | null
exit_date: string | null
exit_reason: string | null
fax: string | null
first_name: string
gdpr_birthday_info: boolean
gdpr_consent: boolean
gdpr_consent_date: string | null
gdpr_data_source: string | null
gdpr_internet: boolean
gdpr_newsletter: boolean
gdpr_print: boolean
gender: string | null
guardian_email: string | null
guardian_name: string | null
guardian_phone: string | null
house_number: string | null
iban: string | null
id: string
is_archived: boolean
is_founding_member: boolean
is_honorary: boolean
is_probationary: boolean
is_retiree: boolean
is_transferred: boolean
is_youth: boolean
last_name: string
member_number: string | null
mobile: string | null
notes: string | null
online_access_blocked: boolean
online_access_key: string | null
phone: string | null
phone2: string | null
postal_code: string | null
salutation: string | null
sepa_bank_name: string | null
sepa_mandate_date: string | null
sepa_mandate_id: string | null
sepa_mandate_reference: string | null
sepa_mandate_sequence: string | null
sepa_mandate_status:
| Database["public"]["Enums"]["sepa_mandate_status"]
| null
status: Database["public"]["Enums"]["membership_status"]
street: string | null
street2: string | null
title: string | null
updated_at: string
updated_by: string | null
user_id: string | null
}
Insert: {
account_holder?: string | null
account_id: string
additional_fees?: number | null
address_invalid?: boolean
bic?: string | null
birth_country?: string | null
birthplace?: string | null
city?: string | null
country?: string | null
created_at?: string
created_by?: string | null
custom_data?: Json
data_reconciliation_needed?: boolean
date_of_birth?: string | null
dues_category_id?: string | null
dues_paid?: boolean
dues_year?: number | null
email?: string | null
email_confirmed?: boolean
entry_date?: string
exemption_amount?: number | null
exemption_reason?: string | null
exemption_type?: string | null
exit_date?: string | null
exit_reason?: string | null
fax?: string | null
first_name: string
gdpr_birthday_info?: boolean
gdpr_consent?: boolean
gdpr_consent_date?: string | null
gdpr_data_source?: string | null
gdpr_internet?: boolean
gdpr_newsletter?: boolean
gdpr_print?: boolean
gender?: string | null
guardian_email?: string | null
guardian_name?: string | null
guardian_phone?: string | null
house_number?: string | null
iban?: string | null
id?: string
is_archived?: boolean
is_founding_member?: boolean
is_honorary?: boolean
is_probationary?: boolean
is_retiree?: boolean
is_transferred?: boolean
is_youth?: boolean
last_name: string
member_number?: string | null
mobile?: string | null
notes?: string | null
online_access_blocked?: boolean
online_access_key?: string | null
phone?: string | null
phone2?: string | null
postal_code?: string | null
salutation?: string | null
sepa_bank_name?: string | null
sepa_mandate_date?: string | null
sepa_mandate_id?: string | null
sepa_mandate_reference?: string | null
sepa_mandate_sequence?: string | null
sepa_mandate_status?:
| Database["public"]["Enums"]["sepa_mandate_status"]
| null
status?: Database["public"]["Enums"]["membership_status"]
street?: string | null
street2?: string | null
title?: string | null
updated_at?: string
updated_by?: string | null
user_id?: string | null
}
Update: {
account_holder?: string | null
account_id?: string
additional_fees?: number | null
address_invalid?: boolean
bic?: string | null
birth_country?: string | null
birthplace?: string | null
city?: string | null
country?: string | null
created_at?: string
created_by?: string | null
custom_data?: Json
data_reconciliation_needed?: boolean
date_of_birth?: string | null
dues_category_id?: string | null
dues_paid?: boolean
dues_year?: number | null
email?: string | null
email_confirmed?: boolean
entry_date?: string
exemption_amount?: number | null
exemption_reason?: string | null
exemption_type?: string | null
exit_date?: string | null
exit_reason?: string | null
fax?: string | null
first_name?: string
gdpr_birthday_info?: boolean
gdpr_consent?: boolean
gdpr_consent_date?: string | null
gdpr_data_source?: string | null
gdpr_internet?: boolean
gdpr_newsletter?: boolean
gdpr_print?: boolean
gender?: string | null
guardian_email?: string | null
guardian_name?: string | null
guardian_phone?: string | null
house_number?: string | null
iban?: string | null
id?: string
is_archived?: boolean
is_founding_member?: boolean
is_honorary?: boolean
is_probationary?: boolean
is_retiree?: boolean
is_transferred?: boolean
is_youth?: boolean
last_name?: string
member_number?: string | null
mobile?: string | null
notes?: string | null
online_access_blocked?: boolean
online_access_key?: string | null
phone?: string | null
phone2?: string | null
postal_code?: string | null
salutation?: string | null
sepa_bank_name?: string | null
sepa_mandate_date?: string | null
sepa_mandate_id?: string | null
sepa_mandate_reference?: string | null
sepa_mandate_sequence?: string | null
sepa_mandate_status?:
| Database["public"]["Enums"]["sepa_mandate_status"]
| null
status?: Database["public"]["Enums"]["membership_status"]
street?: string | null
street2?: string | null
title?: string | null
updated_at?: string
updated_by?: string | null
user_id?: string | null
}
Relationships: [
{
@@ -2412,6 +2858,64 @@ export type Database = {
},
]
}
newsletter_subscriptions: {
Row: {
account_id: string
confirmation_token: string | null
confirmed_at: string | null
email: string
id: string
is_active: boolean
name: string | null
subscribed_at: string
unsubscribed_at: string | null
}
Insert: {
account_id: string
confirmation_token?: string | null
confirmed_at?: string | null
email: string
id?: string
is_active?: boolean
name?: string | null
subscribed_at?: string
unsubscribed_at?: string | null
}
Update: {
account_id?: string
confirmation_token?: string | null
confirmed_at?: string | null
email?: string
id?: string
is_active?: boolean
name?: string | null
subscribed_at?: string
unsubscribed_at?: string | null
}
Relationships: [
{
foreignKeyName: "newsletter_subscriptions_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "accounts"
referencedColumns: ["id"]
},
{
foreignKeyName: "newsletter_subscriptions_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_account_workspace"
referencedColumns: ["id"]
},
{
foreignKeyName: "newsletter_subscriptions_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_accounts"
referencedColumns: ["id"]
},
]
}
newsletter_templates: {
Row: {
account_id: string
@@ -3015,6 +3519,259 @@ export type Database = {
},
]
}
sepa_mandates: {
Row: {
account_holder: string
account_id: string
bic: string | null
created_at: string
has_error: boolean
iban: string
id: string
is_primary: boolean
last_used_at: string | null
mandate_date: string
mandate_reference: string
member_id: string
notes: string | null
sequence: string
status: Database["public"]["Enums"]["sepa_mandate_status"]
updated_at: string
}
Insert: {
account_holder: string
account_id: string
bic?: string | null
created_at?: string
has_error?: boolean
iban: string
id?: string
is_primary?: boolean
last_used_at?: string | null
mandate_date: string
mandate_reference: string
member_id: string
notes?: string | null
sequence?: string
status?: Database["public"]["Enums"]["sepa_mandate_status"]
updated_at?: string
}
Update: {
account_holder?: string
account_id?: string
bic?: string | null
created_at?: string
has_error?: boolean
iban?: string
id?: string
is_primary?: boolean
last_used_at?: string | null
mandate_date?: string
mandate_reference?: string
member_id?: string
notes?: string | null
sequence?: string
status?: Database["public"]["Enums"]["sepa_mandate_status"]
updated_at?: string
}
Relationships: [
{
foreignKeyName: "sepa_mandates_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "accounts"
referencedColumns: ["id"]
},
{
foreignKeyName: "sepa_mandates_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_account_workspace"
referencedColumns: ["id"]
},
{
foreignKeyName: "sepa_mandates_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_accounts"
referencedColumns: ["id"]
},
{
foreignKeyName: "sepa_mandates_member_id_fkey"
columns: ["member_id"]
isOneToOne: false
referencedRelation: "members"
referencedColumns: ["id"]
},
]
}
site_pages: {
Row: {
account_id: string
created_at: string
created_by: string | null
id: string
is_homepage: boolean
is_members_only: boolean
is_published: boolean
meta_description: string | null
meta_image: string | null
published_at: string | null
puck_data: Json
slug: string
sort_order: number
title: string
updated_at: string
updated_by: string | null
}
Insert: {
account_id: string
created_at?: string
created_by?: string | null
id?: string
is_homepage?: boolean
is_members_only?: boolean
is_published?: boolean
meta_description?: string | null
meta_image?: string | null
published_at?: string | null
puck_data?: Json
slug: string
sort_order?: number
title: string
updated_at?: string
updated_by?: string | null
}
Update: {
account_id?: string
created_at?: string
created_by?: string | null
id?: string
is_homepage?: boolean
is_members_only?: boolean
is_published?: boolean
meta_description?: string | null
meta_image?: string | null
published_at?: string | null
puck_data?: Json
slug?: string
sort_order?: number
title?: string
updated_at?: string
updated_by?: string | null
}
Relationships: [
{
foreignKeyName: "site_pages_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "accounts"
referencedColumns: ["id"]
},
{
foreignKeyName: "site_pages_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_account_workspace"
referencedColumns: ["id"]
},
{
foreignKeyName: "site_pages_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_accounts"
referencedColumns: ["id"]
},
]
}
site_settings: {
Row: {
account_id: string
contact_address: string | null
contact_email: string | null
contact_phone: string | null
created_at: string
custom_css: string | null
custom_domain: string | null
datenschutz: string | null
font_family: string | null
footer_text: string | null
impressum: string | null
is_public: boolean
navigation: Json
primary_color: string | null
secondary_color: string | null
site_logo: string | null
site_name: string | null
social_links: Json | null
updated_at: string
}
Insert: {
account_id: string
contact_address?: string | null
contact_email?: string | null
contact_phone?: string | null
created_at?: string
custom_css?: string | null
custom_domain?: string | null
datenschutz?: string | null
font_family?: string | null
footer_text?: string | null
impressum?: string | null
is_public?: boolean
navigation?: Json
primary_color?: string | null
secondary_color?: string | null
site_logo?: string | null
site_name?: string | null
social_links?: Json | null
updated_at?: string
}
Update: {
account_id?: string
contact_address?: string | null
contact_email?: string | null
contact_phone?: string | null
created_at?: string
custom_css?: string | null
custom_domain?: string | null
datenschutz?: string | null
font_family?: string | null
footer_text?: string | null
impressum?: string | null
is_public?: boolean
navigation?: Json
primary_color?: string | null
secondary_color?: string | null
site_logo?: string | null
site_name?: string | null
social_links?: Json | null
updated_at?: string
}
Relationships: [
{
foreignKeyName: "site_settings_account_id_fkey"
columns: ["account_id"]
isOneToOne: true
referencedRelation: "accounts"
referencedColumns: ["id"]
},
{
foreignKeyName: "site_settings_account_id_fkey"
columns: ["account_id"]
isOneToOne: true
referencedRelation: "user_account_workspace"
referencedColumns: ["id"]
},
{
foreignKeyName: "site_settings_account_id_fkey"
columns: ["account_id"]
isOneToOne: true
referencedRelation: "user_accounts"
referencedColumns: ["id"]
},
]
}
subscription_items: {
Row: {
created_at: string
@@ -3194,6 +3951,22 @@ export type Database = {
Args: { target_team_account_id: string; target_user_id: string }
Returns: boolean
}
check_duplicate_member: {
Args: {
p_account_id: string
p_date_of_birth?: string
p_first_name: string
p_last_name: string
}
Returns: {
date_of_birth: string
first_name: string
id: string
last_name: string
member_number: string
status: Database["public"]["Enums"]["membership_status"]
}[]
}
create_invitation: {
Args: { account_id: string; email: string; role: string }
Returns: {
@@ -3327,6 +4100,10 @@ export type Database = {
Args: { account_id: string; user_id: string }
Returns: boolean
}
link_member_to_user: {
Args: { p_invite_token: string; p_user_id: string }
Returns: string
}
module_query: {
Args: {
p_filters?: Json