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

@@ -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,
};