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