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,200 @@
|
||||
'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 { approveApplication, rejectApplication } from '../server/actions/member-actions';
|
||||
|
||||
interface ApplicationWorkflowProps {
|
||||
applications: Array<Record<string, unknown>>;
|
||||
accountId: string;
|
||||
account: string;
|
||||
}
|
||||
|
||||
const APPLICATION_STATUS_LABELS: Record<string, string> = {
|
||||
submitted: 'Eingereicht',
|
||||
review: 'In Prüfung',
|
||||
approved: 'Genehmigt',
|
||||
rejected: 'Abgelehnt',
|
||||
};
|
||||
|
||||
function getApplicationStatusColor(
|
||||
status: string,
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return 'default';
|
||||
case 'submitted':
|
||||
case 'review':
|
||||
return 'outline';
|
||||
case 'rejected':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
export function ApplicationWorkflow({
|
||||
applications,
|
||||
accountId,
|
||||
account,
|
||||
}: ApplicationWorkflowProps) {
|
||||
const router = useRouter();
|
||||
const form = useForm();
|
||||
|
||||
const { execute: executeApprove, isPending: isApproving } = useAction(
|
||||
approveApplication,
|
||||
{
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Antrag genehmigt – Mitglied wurde erstellt');
|
||||
router.refresh();
|
||||
}
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Genehmigen');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { execute: executeReject, isPending: isRejecting } = useAction(
|
||||
rejectApplication,
|
||||
{
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Antrag wurde abgelehnt');
|
||||
router.refresh();
|
||||
}
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Ablehnen');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleApprove = useCallback(
|
||||
(applicationId: string) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
'Mitglied wird automatisch erstellt. Fortfahren?',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
executeApprove({ applicationId, accountId });
|
||||
},
|
||||
[executeApprove, accountId],
|
||||
);
|
||||
|
||||
const handleReject = useCallback(
|
||||
(applicationId: string) => {
|
||||
const reason = window.prompt(
|
||||
'Bitte geben Sie einen Ablehnungsgrund ein:',
|
||||
);
|
||||
if (reason === null) return; // cancelled
|
||||
executeReject({
|
||||
applicationId,
|
||||
accountId,
|
||||
reviewNotes: reason,
|
||||
});
|
||||
},
|
||||
[executeReject, accountId],
|
||||
);
|
||||
|
||||
const isPending = isApproving || isRejecting;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Aufnahmeanträge</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{applications.length} Antrag{applications.length !== 1 ? 'e' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium">E-Mail</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-right font-medium">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{applications.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
Keine Aufnahmeanträge vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
applications.map((app) => {
|
||||
const appId = String(app.id ?? '');
|
||||
const appStatus = String(app.status ?? 'submitted');
|
||||
const isActionable =
|
||||
appStatus === 'submitted' || appStatus === 'review';
|
||||
|
||||
return (
|
||||
<tr key={appId} className="border-b">
|
||||
<td className="px-4 py-3">
|
||||
{String(app.last_name ?? '')},{' '}
|
||||
{String(app.first_name ?? '')}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{String(app.email ?? '—')}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{app.created_at
|
||||
? new Date(String(app.created_at)).toLocaleDateString(
|
||||
'de-DE',
|
||||
)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={getApplicationStatusColor(appStatus)}>
|
||||
{APPLICATION_STATUS_LABELS[appStatus] ?? appStatus}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{isActionable && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
disabled={isPending}
|
||||
onClick={() => handleApprove(appId)}
|
||||
>
|
||||
Genehmigen
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => handleReject(appId)}
|
||||
>
|
||||
Ablehnen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user