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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user