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