feat: MyEasyCMS v2 — Full SaaS rebuild
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:
190
packages/features/finance/src/components/create-invoice-form.tsx
Normal file
190
packages/features/finance/src/components/create-invoice-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export {};
|
||||
export { CreateInvoiceForm } from './create-invoice-form';
|
||||
export { CreateSepaBatchForm } from './create-sepa-batch-form';
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user