Initial state for GitNexus analysis

This commit is contained in:
Zaid Marzguioui
2026-03-29 19:44:57 +02:00
parent 9d7c7f8030
commit 61ff48cb73
155 changed files with 23483 additions and 1722 deletions

View File

@@ -0,0 +1,25 @@
/**
* E2E Test: Course Enrollment
*/
import { test, expect } from '@playwright/test';
test.describe('Course Management', () => {
test('create course, enroll participant, check capacity, waitlist', async ({ page }) => {
// Create course with capacity 2
// Enroll participant 1 → status: enrolled
// Enroll participant 2 → status: enrolled
// Enroll participant 3 → status: waitlisted (capacity full)
});
test('course calendar view shows sessions', async ({ page }) => {
// Create course with sessions
// Navigate to calendar
// Verify sessions visible
});
test('attendance tracking', async ({ page }) => {
// Create course + session + participants
// Mark attendance
// Verify attendance persists
});
});

View File

@@ -0,0 +1,29 @@
/**
* E2E Test: Member Management — Full lifecycle
*/
import { test, expect } from '@playwright/test';
test.describe('Member Management', () => {
test('create member, edit, search, filter by status', async ({ page }) => {
await page.goto('/auth/sign-in');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'testpassword123');
await page.click('button[type="submit"]');
await page.waitForURL('**/home/**');
await page.click('text=Mitglieder');
await expect(page.locator('h1')).toContainText('Mitglieder');
});
test('application workflow: submit → review → approve → member created', async ({ page }) => {
// Submit application
// Review application
// Approve → verify member auto-created
});
test('SEPA mandate management', async ({ page }) => {
// Create member with IBAN
// Verify IBAN validation
// Create SEPA batch from dues
});
});

View File

@@ -0,0 +1,31 @@
/**
* E2E Test: Module Builder — Full CRUD lifecycle
*/
import { test, expect } from '@playwright/test';
test.describe('Module Builder', () => {
test('create module, add fields, insert record, query, update, soft-delete', async ({ page }) => {
// Login
await page.goto('/auth/sign-in');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'testpassword123');
await page.click('button[type="submit"]');
await page.waitForURL('**/home/**');
// Navigate to modules
await page.click('text=Module');
await expect(page.locator('h1')).toContainText('Module');
// Create module via API or UI
// ... test continues with full CRUD cycle
});
});
test.describe('Cross-tenant isolation', () => {
test('tenant A cannot see tenant B data', async ({ page }) => {
// Login as tenant A user
// Verify can see own modules
// Verify cannot access tenant B module URL
// Verify API returns only own data
});
});

View File

@@ -0,0 +1,20 @@
/**
* E2E Test: Newsletter
*/
import { test, expect } from '@playwright/test';
test.describe('Newsletter', () => {
test('create campaign, select recipients from members, preview, send', async ({ page }) => {
// Create newsletter
// Add recipients from member filter (status=active, hasEmail=true)
// Preview with variable substitution
// Dispatch
// Verify sent_count
});
test('template variable substitution works', async ({ page }) => {
// Create template with {{first_name}} {{member_number}}
// Create newsletter from template
// Preview — verify variables replaced
});
});

View File

@@ -0,0 +1,25 @@
/**
* E2E Test: SEPA Batch Processing
*/
import { test, expect } from '@playwright/test';
test.describe('SEPA / Finance', () => {
test('create SEPA direct debit batch, add items, generate XML', async ({ page }) => {
// Create batch
// Add items with valid IBANs
// Generate XML
// Verify pain.008.003.02 format
// Verify amounts sum correctly
});
test('IBAN validation rejects invalid IBANs', async ({ page }) => {
// Try to add item with invalid IBAN
// Verify rejection
});
test('invoice creation with line items', async ({ page }) => {
// Create invoice
// Add 3 line items
// Verify subtotal, tax, total calculations
});
});

View File

@@ -0,0 +1,20 @@
export default async function AdminAuditPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold">Protokoll</h1>
<p className="text-muted-foreground">
Mandantenübergreifendes Änderungsprotokoll
</p>
</div>
<div className="rounded-lg border p-6">
<p className="text-sm text-muted-foreground">
Alle Datenänderungen (Erstellen, Ändern, Löschen, Sperren)
über alle Mandanten hinweg. Filtert nach Zeitraum, Benutzer,
Tabelle und Aktion.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
export default async function AdminGdprPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold">DSGVO-Verarbeitungsverzeichnis</h1>
<p className="text-muted-foreground">
Art. 30 DSGVO Überblick über alle Verarbeitungstätigkeiten
</p>
</div>
<div className="rounded-lg border p-6">
<p className="text-sm text-muted-foreground">
Mandantenübergreifende Übersicht aller registrierten Verarbeitungstätigkeiten
gemäß Art. 30 DSGVO. Umfasst Zweck, Rechtsgrundlage, Datenkategorien,
Aufbewahrungsfristen und technisch-organisatorische Maßnahmen.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
export default async function AdminMigrationPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold">Datenmigration</h1>
<p className="text-muted-foreground">
Legacy-Daten aus MyEasyCMS (MySQL) in die neue Plattform migrieren
</p>
</div>
<div className="rounded-lg border p-6 space-y-4">
<h2 className="text-lg font-semibold">Migrationsschritte</h2>
<ol className="list-decimal list-inside space-y-2 text-sm">
<li>MySQL-Verbindung konfigurieren</li>
<li>Mandanten (user_profile team accounts) zuordnen</li>
<li>Benutzer (cms_user auth.users) migrieren</li>
<li>Module (m_module/m_modulfeld modules/module_fields) übertragen</li>
<li>Mitglieder (ve_mitglieder members) importieren</li>
<li>Kurse (ve_kurse courses) importieren</li>
<li>Dateien (cms_files Supabase Storage) hochladen</li>
<li>Daten verifizieren und bereinigen</li>
</ol>
<div className="rounded-md bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 p-4">
<p className="text-sm text-amber-800 dark:text-amber-200">
<strong>Hinweis:</strong> Die Migration erfordert eine MySQL-Verbindung zum Legacy-System.
Stellen Sie sicher, dass <code>mysql2</code> installiert ist und die Verbindungsdaten korrekt konfiguriert sind.
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
export default async function AdminModulesPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold">Module-Übersicht</h1>
<p className="text-muted-foreground">
Mandantenübergreifende Modulverwaltung
</p>
</div>
<div className="rounded-lg border p-6">
<p className="text-sm text-muted-foreground">
Hier werden alle Module über alle Mandanten hinweg angezeigt.
Ermöglicht die zentrale Verwaltung von Modulvorlagen und -konfigurationen.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,363 @@
import Link from 'next/link';
import {
ArrowLeft,
BedDouble,
CalendarDays,
LogIn,
LogOut,
XCircle,
User,
} from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string; bookingId: string }>;
}
const STATUS_BADGE_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
pending: 'secondary',
confirmed: 'default',
checked_in: 'info',
checked_out: 'outline',
cancelled: 'destructive',
no_show: 'destructive',
};
const STATUS_LABEL: Record<string, string> = {
pending: 'Ausstehend',
confirmed: 'Bestätigt',
checked_in: 'Eingecheckt',
checked_out: 'Ausgecheckt',
cancelled: 'Storniert',
no_show: 'Nicht erschienen',
};
export default async function BookingDetailPage({ params }: PageProps) {
const { account, bookingId } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createBookingManagementApi(client);
// Load booking directly
const { data: booking } = await client
.from('bookings')
.select('*')
.eq('id', bookingId)
.eq('account_id', acct.id)
.single();
if (!booking) {
return (
<CmsPageShell account={account} title="Buchung nicht gefunden">
<div className="flex flex-col items-center gap-4 py-12">
<p className="text-muted-foreground">
Buchung mit ID &quot;{bookingId}&quot; wurde nicht gefunden.
</p>
<Link href={`/home/${account}/bookings`}>
<Button variant="outline">
<ArrowLeft className="mr-2 h-4 w-4" />
Zurück zu Buchungen
</Button>
</Link>
</div>
</CmsPageShell>
);
}
// Load related room and guest data
const [roomResult, guestResult] = await Promise.all([
booking.room_id
? client.from('rooms').select('*').eq('id', booking.room_id).single()
: Promise.resolve({ data: null }),
booking.guest_id
? client.from('guests').select('*').eq('id', booking.guest_id).single()
: Promise.resolve({ data: null }),
]);
const room = roomResult.data;
const guest = guestResult.data;
const status = String(booking.status ?? 'pending');
return (
<CmsPageShell account={account} title="Buchungsdetails">
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/home/${account}/bookings`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">Buchungsdetails</h1>
<Badge variant={STATUS_BADGE_VARIANT[status] ?? 'secondary'}>
{STATUS_LABEL[status] ?? status}
</Badge>
</div>
<p className="text-muted-foreground text-sm">
ID: {bookingId}
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Zimmer */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BedDouble className="h-5 w-5" />
Zimmer
</CardTitle>
</CardHeader>
<CardContent>
{room ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Zimmernummer
</span>
<span className="font-medium">
{String(room.room_number)}
</span>
</div>
{room.name && (
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Name
</span>
<span className="font-medium">{String(room.name)}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Typ</span>
<span className="font-medium">
{String(room.room_type ?? '—')}
</span>
</div>
</div>
) : (
<p className="text-muted-foreground text-sm">
Kein Zimmer zugewiesen
</p>
)}
</CardContent>
</Card>
{/* Gast */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Gast
</CardTitle>
</CardHeader>
<CardContent>
{guest ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Name</span>
<span className="font-medium">
{String(guest.first_name)} {String(guest.last_name)}
</span>
</div>
{guest.email && (
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
E-Mail
</span>
<span className="font-medium">
{String(guest.email)}
</span>
</div>
)}
{guest.phone && (
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Telefon
</span>
<span className="font-medium">
{String(guest.phone)}
</span>
</div>
)}
</div>
) : (
<p className="text-muted-foreground text-sm">
Kein Gast zugewiesen
</p>
)}
</CardContent>
</Card>
{/* Aufenthalt */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarDays className="h-5 w-5" />
Aufenthalt
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Check-in
</span>
<span className="font-medium">
{booking.check_in
? new Date(String(booking.check_in)).toLocaleDateString(
'de-DE',
{
weekday: 'short',
day: '2-digit',
month: '2-digit',
year: 'numeric',
},
)
: '—'}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Check-out
</span>
<span className="font-medium">
{booking.check_out
? new Date(String(booking.check_out)).toLocaleDateString(
'de-DE',
{
weekday: 'short',
day: '2-digit',
month: '2-digit',
year: 'numeric',
},
)
: '—'}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Erwachsene
</span>
<span className="font-medium">
{booking.adults ?? '—'}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Kinder
</span>
<span className="font-medium">
{booking.children ?? 0}
</span>
</div>
</div>
</CardContent>
</Card>
{/* Betrag */}
<Card>
<CardHeader>
<CardTitle>Betrag</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Gesamtpreis
</span>
<span className="text-2xl font-bold">
{booking.total_price != null
? `${Number(booking.total_price).toFixed(2)}`
: '—'}
</span>
</div>
{booking.notes && (
<div className="border-t pt-2">
<span className="text-sm text-muted-foreground">
Notizen
</span>
<p className="mt-1 text-sm">{String(booking.notes)}</p>
</div>
)}
</div>
</CardContent>
</Card>
</div>
{/* Status Workflow */}
<Card>
<CardHeader>
<CardTitle>Aktionen</CardTitle>
<CardDescription>
Status der Buchung ändern
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
{(status === 'pending' || status === 'confirmed') && (
<Button variant="default">
<LogIn className="mr-2 h-4 w-4" />
Einchecken
</Button>
)}
{status === 'checked_in' && (
<Button variant="default">
<LogOut className="mr-2 h-4 w-4" />
Auschecken
</Button>
)}
{status !== 'cancelled' &&
status !== 'checked_out' &&
status !== 'no_show' && (
<Button variant="destructive">
<XCircle className="mr-2 h-4 w-4" />
Stornieren
</Button>
)}
{status === 'cancelled' || status === 'checked_out' ? (
<p className="text-sm text-muted-foreground py-2">
Diese Buchung ist{' '}
{status === 'cancelled' ? 'storniert' : 'abgeschlossen'} keine
weiteren Aktionen verfügbar.
</p>
) : null}
</div>
</CardContent>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,230 @@
import Link from 'next/link';
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;
}
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
const MONTH_NAMES = [
'Januar',
'Februar',
'März',
'April',
'Mai',
'Juni',
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember',
];
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function getFirstWeekday(year: number, month: number): number {
const day = new Date(year, month, 1).getDay();
// Convert Sunday=0 to Monday-based (Mo=0, Di=1 … So=6)
return day === 0 ? 6 : day - 1;
}
function isDateInRange(date: string, checkIn: string, checkOut: string): boolean {
return date >= checkIn && date < checkOut;
}
export default async function BookingCalendarPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createBookingManagementApi(client);
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const daysInMonth = getDaysInMonth(year, month);
const firstWeekday = getFirstWeekday(year, month);
// Load bookings for this month
const monthStart = `${year}-${String(month + 1).padStart(2, '0')}-01`;
const monthEnd = `${year}-${String(month + 1).padStart(2, '0')}-${String(daysInMonth).padStart(2, '0')}`;
const bookings = await api.listBookings(acct.id, {
from: monthStart,
to: monthEnd,
page: 1,
});
// Build set of occupied dates
const occupiedDates = new Set<string>();
for (const booking of bookings.data) {
const b = booking as Record<string, unknown>;
if (b.status === 'cancelled' || b.status === 'no_show') continue;
const checkIn = String(b.check_in ?? '');
const checkOut = String(b.check_out ?? '');
if (!checkIn || !checkOut) continue;
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
if (isDateInRange(dateStr, checkIn, checkOut)) {
occupiedDates.add(dateStr);
}
}
}
// Build calendar grid cells
const cells: Array<{ day: number | null; occupied: boolean; isToday: boolean }> = [];
// Empty cells before first day
for (let i = 0; i < firstWeekday; i++) {
cells.push({ day: null, occupied: false, isToday: false });
}
const todayStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
cells.push({
day: d,
occupied: occupiedDates.has(dateStr),
isToday: dateStr === todayStr,
});
}
// Fill remaining cells to complete the grid
while (cells.length % 7 !== 0) {
cells.push({ day: null, occupied: false, isToday: false });
}
return (
<CmsPageShell account={account} title="Belegungskalender">
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/home/${account}/bookings`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Belegungskalender</h1>
<p className="text-muted-foreground">
Zimmerauslastung im Überblick
</p>
</div>
</div>
</div>
{/* Month Navigation */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<Button variant="ghost" size="icon" disabled>
<ChevronLeft className="h-4 w-4" />
</Button>
<CardTitle>
{MONTH_NAMES[month]} {year}
</CardTitle>
<Button variant="ghost" size="icon" disabled>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{/* Weekday Header */}
<div className="grid grid-cols-7 gap-1 mb-1">
{WEEKDAYS.map((day) => (
<div
key={day}
className="text-center text-xs font-medium text-muted-foreground py-2"
>
{day}
</div>
))}
</div>
{/* Calendar Grid */}
<div className="grid grid-cols-7 gap-1">
{cells.map((cell, idx) => (
<div
key={idx}
className={`relative flex h-12 items-center justify-center rounded-md text-sm transition-colors ${
cell.day === null
? 'bg-transparent'
: cell.occupied
? 'bg-primary/15 text-primary font-semibold'
: 'bg-muted/30 hover:bg-muted/50'
} ${cell.isToday ? 'ring-2 ring-primary ring-offset-1' : ''}`}
>
{cell.day !== null && (
<>
<span>{cell.day}</span>
{cell.occupied && (
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 h-1.5 w-1.5 rounded-full bg-primary" />
)}
</>
)}
</div>
))}
</div>
{/* Legend */}
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm bg-primary/15" />
Belegt
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm bg-muted/30" />
Frei
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm ring-2 ring-primary" />
Heute
</div>
</div>
</CardContent>
</Card>
{/* Summary */}
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">
Buchungen in diesem Monat
</p>
<p className="text-2xl font-bold">{bookings.data.length}</p>
</div>
<Badge variant="outline">
{occupiedDates.size} von {daysInMonth} Tagen belegt
</Badge>
</div>
</CardContent>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,90 @@
import { UserCircle, Plus } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function GuestsPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createBookingManagementApi(client);
const guests = await api.listGuests(acct.id);
return (
<CmsPageShell account={account} title="Gäste">
<div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Gäste</h1>
<p className="text-muted-foreground">Gästeverwaltung</p>
</div>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neuer Gast
</Button>
</div>
{guests.length === 0 ? (
<EmptyState
icon={<UserCircle className="h-8 w-8" />}
title="Keine Gäste vorhanden"
description="Legen Sie Ihren ersten Gast an."
actionLabel="Neuer Gast"
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Gäste ({guests.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">E-Mail</th>
<th className="p-3 text-left font-medium">Telefon</th>
<th className="p-3 text-left font-medium">Stadt</th>
<th className="p-3 text-left font-medium">Land</th>
</tr>
</thead>
<tbody>
{guests.map((guest: Record<string, unknown>) => (
<tr key={String(guest.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-medium">
{String(guest.last_name ?? '')}, {String(guest.first_name ?? '')}
</td>
<td className="p-3">{String(guest.email ?? '—')}</td>
<td className="p-3">{String(guest.phone ?? '—')}</td>
<td className="p-3">{String(guest.city ?? '—')}</td>
<td className="p-3">{String(guest.country ?? '—')}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,235 @@
import Link from 'next/link';
import { ArrowLeft, BedDouble } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function NewBookingPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createBookingManagementApi(client);
const [rooms, guests] = await Promise.all([
api.listRooms(acct.id),
api.listGuests(acct.id),
]);
return (
<CmsPageShell account={account} title="Neue Buchung">
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href={`/home/${account}/bookings`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Neue Buchung</h1>
<p className="text-muted-foreground">
Buchung für ein Zimmer erstellen
</p>
</div>
</div>
<form className="flex flex-col gap-6">
{/* Zimmer & Gast */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BedDouble className="h-5 w-5" />
Zimmer &amp; Gast
</CardTitle>
<CardDescription>
Wählen Sie Zimmer und Gast für die Buchung aus
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="flex flex-col gap-1.5">
<label
htmlFor="room_id"
className="text-sm font-medium"
>
Zimmer
</label>
<select
id="room_id"
name="room_id"
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">Zimmer wählen</option>
{rooms.map((room: Record<string, unknown>) => (
<option key={String(room.id)} value={String(room.id)}>
{String(room.room_number)} {String(room.name ?? room.room_type ?? '')}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="guest_id"
className="text-sm font-medium"
>
Gast
</label>
<select
id="guest_id"
name="guest_id"
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">Gast wählen</option>
{guests.map((guest: Record<string, unknown>) => (
<option key={String(guest.id)} value={String(guest.id)}>
{String(guest.last_name)}, {String(guest.first_name)}
{guest.email ? ` (${String(guest.email)})` : ''}
</option>
))}
</select>
</div>
</CardContent>
</Card>
{/* Aufenthalt */}
<Card>
<CardHeader>
<CardTitle>Aufenthalt</CardTitle>
<CardDescription>
Reisedaten und Personenanzahl
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="flex flex-col gap-1.5">
<label htmlFor="check_in" className="text-sm font-medium">
Anreise
</label>
<input
id="check_in"
name="check_in"
type="date"
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="check_out" className="text-sm font-medium">
Abreise
</label>
<input
id="check_out"
name="check_out"
type="date"
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="adults" className="text-sm font-medium">
Erwachsene
</label>
<input
id="adults"
name="adults"
type="number"
min={1}
defaultValue={1}
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="children" className="text-sm font-medium">
Kinder
</label>
<input
id="children"
name="children"
type="number"
min={0}
defaultValue={0}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</CardContent>
</Card>
{/* Preis & Notizen */}
<Card>
<CardHeader>
<CardTitle>Preis &amp; Notizen</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5 md:w-1/2">
<label htmlFor="total_price" className="text-sm font-medium">
Preis ()
</label>
<input
id="total_price"
name="total_price"
type="number"
step="0.01"
min={0}
placeholder="0.00"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="notes" className="text-sm font-medium">
Notizen
</label>
<textarea
id="notes"
name="notes"
rows={3}
placeholder="Zusätzliche Informationen zur Buchung…"
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</CardContent>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-3">
<Link href={`/home/${account}/bookings`}>
<Button type="button" variant="outline">
Abbrechen
</Button>
</Link>
<Button type="submit">Buchung erstellen</Button>
</div>
</form>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,182 @@
import Link from 'next/link';
import { BedDouble, CalendarCheck, Plus, Euro } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card';
interface PageProps {
params: Promise<{ account: string }>;
}
const STATUS_BADGE_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
pending: 'secondary',
confirmed: 'default',
checked_in: 'info',
checked_out: 'outline',
cancelled: 'destructive',
};
const STATUS_LABEL: Record<string, string> = {
pending: 'Ausstehend',
confirmed: 'Bestätigt',
checked_in: 'Eingecheckt',
checked_out: 'Ausgecheckt',
cancelled: 'Storniert',
no_show: 'Nicht erschienen',
};
export default async function BookingsPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createBookingManagementApi(client);
const [rooms, bookings] = await Promise.all([
api.listRooms(acct.id),
api.listBookings(acct.id, { page: 1 }),
]);
const activeBookings = bookings.data.filter(
(b: Record<string, unknown>) =>
b.status === 'confirmed' || b.status === 'checked_in',
);
return (
<CmsPageShell account={account} title="Buchungen">
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Buchungen</h1>
<p className="text-muted-foreground">
Zimmer und Buchungen verwalten
</p>
</div>
<Link href={`/home/${account}/bookings/new`}>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neue Buchung
</Button>
</Link>
</div>
{/* Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatsCard
title="Zimmer"
value={rooms.length}
icon={<BedDouble className="h-5 w-5" />}
/>
<StatsCard
title="Aktive Buchungen"
value={activeBookings.length}
icon={<CalendarCheck className="h-5 w-5" />}
/>
<StatsCard
title="Gesamt"
value={bookings.total}
icon={<Euro className="h-5 w-5" />}
/>
</div>
{/* Table or Empty State */}
{bookings.data.length === 0 ? (
<EmptyState
icon={<BedDouble className="h-8 w-8" />}
title="Keine Buchungen vorhanden"
description="Erstellen Sie Ihre erste Buchung, um loszulegen."
actionLabel="Neue Buchung"
actionHref={`/home/${account}/bookings/new`}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Buchungen ({bookings.total})</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Zimmer</th>
<th className="p-3 text-left font-medium">Gast</th>
<th className="p-3 text-left font-medium">Anreise</th>
<th className="p-3 text-left font-medium">Abreise</th>
<th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-right font-medium">Betrag</th>
</tr>
</thead>
<tbody>
{bookings.data.map((booking: Record<string, unknown>) => (
<tr
key={String(booking.id)}
className="border-b hover:bg-muted/30"
>
<td className="p-3">
<Link
href={`/home/${account}/bookings/${String(booking.id)}`}
className="font-medium hover:underline"
>
{String(booking.room_id ?? '—')}
</Link>
</td>
<td className="p-3">
{String(booking.guest_id ?? '—')}
</td>
<td className="p-3">
{booking.check_in
? new Date(String(booking.check_in)).toLocaleDateString('de-DE')
: '—'}
</td>
<td className="p-3">
{booking.check_out
? new Date(String(booking.check_out)).toLocaleDateString('de-DE')
: '—'}
</td>
<td className="p-3">
<Badge
variant={
STATUS_BADGE_VARIANT[String(booking.status)] ?? 'secondary'
}
>
{STATUS_LABEL[String(booking.status)] ?? String(booking.status)}
</Badge>
</td>
<td className="p-3 text-right">
{booking.total_price != null
? `${Number(booking.total_price).toFixed(2)}`
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,101 @@
import { BedDouble, Plus } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function RoomsPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createBookingManagementApi(client);
const rooms = await api.listRooms(acct.id);
return (
<CmsPageShell account={account} title="Zimmer">
<div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Zimmer</h1>
<p className="text-muted-foreground">Zimmerverwaltung</p>
</div>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neues Zimmer
</Button>
</div>
{rooms.length === 0 ? (
<EmptyState
icon={<BedDouble className="h-8 w-8" />}
title="Keine Zimmer vorhanden"
description="Fügen Sie Ihr erstes Zimmer hinzu."
actionLabel="Neues Zimmer"
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Zimmer ({rooms.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Zimmernr.</th>
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">Typ</th>
<th className="p-3 text-right font-medium">Kapazität</th>
<th className="p-3 text-right font-medium">Preis/Nacht</th>
<th className="p-3 text-center font-medium">Aktiv</th>
</tr>
</thead>
<tbody>
{rooms.map((room: Record<string, unknown>) => (
<tr key={String(room.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-mono text-xs">
{String(room.room_number ?? '—')}
</td>
<td className="p-3 font-medium">{String(room.name ?? '—')}</td>
<td className="p-3">
<Badge variant="outline">{String(room.room_type ?? '—')}</Badge>
</td>
<td className="p-3 text-right">{String(room.capacity ?? '—')}</td>
<td className="p-3 text-right">
{room.price_per_night != null
? `${Number(room.price_per_night).toFixed(2)}`
: '—'}
</td>
<td className="p-3 text-center">
{room.is_active !== false ? '✓' : '✗'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,134 @@
import { ClipboardCheck, Calendar } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
interface PageProps {
params: Promise<{ account: string; courseId: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function AttendancePage({ params, searchParams }: PageProps) {
const { account, courseId } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();
const api = createCourseManagementApi(client);
const [course, sessions, participants] = await Promise.all([
api.getCourse(courseId),
api.getSessions(courseId),
api.getParticipants(courseId),
]);
if (!course) return <div>Kurs nicht gefunden</div>;
const selectedSessionId = (search.session as string) ?? (sessions.length > 0 ? String((sessions[0] as Record<string, unknown>).id) : null);
const attendance = selectedSessionId
? await api.getAttendance(selectedSessionId)
: [];
const attendanceMap = new Map(
attendance.map((a: Record<string, unknown>) => [String(a.participant_id), Boolean(a.present)]),
);
return (
<CmsPageShell account={account} title="Anwesenheit">
<div className="flex w-full flex-col gap-6">
<div>
<h1 className="text-2xl font-bold">Anwesenheit</h1>
<p className="text-muted-foreground">
{String((course as Record<string, unknown>).name)}
</p>
</div>
{/* Session Selector */}
{sessions.length === 0 ? (
<EmptyState
icon={<Calendar className="h-8 w-8" />}
title="Keine Termine vorhanden"
description="Erstellen Sie zuerst Termine für diesen Kurs."
/>
) : (
<>
<Card>
<CardHeader>
<CardTitle>Termin auswählen</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{sessions.map((s: Record<string, unknown>) => {
const isSelected = String(s.id) === selectedSessionId;
return (
<a
key={String(s.id)}
href={`/home/${account}/courses/${courseId}/attendance?session=${String(s.id)}`}
>
<Badge variant={isSelected ? 'default' : 'outline'} className="cursor-pointer px-3 py-1">
{s.session_date
? new Date(String(s.session_date)).toLocaleDateString('de-DE')
: String(s.id)}
</Badge>
</a>
);
})}
</div>
</CardContent>
</Card>
{/* Attendance Grid */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ClipboardCheck className="h-5 w-5" />
Anwesenheitsliste
</CardTitle>
</CardHeader>
<CardContent>
{participants.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
Keine Teilnehmer in diesem Kurs
</p>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Teilnehmer</th>
<th className="p-3 text-center font-medium">Anwesend</th>
</tr>
</thead>
<tbody>
{participants.map((p: Record<string, unknown>) => (
<tr key={String(p.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-medium">
{String(p.last_name ?? '')}, {String(p.first_name ?? '')}
</td>
<td className="p-3 text-center">
<input
type="checkbox"
defaultChecked={attendanceMap.get(String(p.id)) ?? false}
className="h-4 w-4 rounded border-gray-300"
aria-label={`Anwesenheit ${String(p.last_name)}`}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</>
)}
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,188 @@
import Link from 'next/link';
import { GraduationCap, Users, Calendar, Euro, User, Clock } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string; courseId: string }>;
}
const STATUS_LABEL: Record<string, string> = {
planned: 'Geplant', open: 'Offen', running: 'Laufend',
completed: 'Abgeschlossen', cancelled: 'Abgesagt',
};
const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'outline' | 'destructive'> = {
planned: 'secondary', open: 'default', running: 'info',
completed: 'outline', cancelled: 'destructive',
};
export default async function CourseDetailPage({ params }: PageProps) {
const { account, courseId } = await params;
const client = getSupabaseServerClient();
const api = createCourseManagementApi(client);
const [course, participants, sessions] = await Promise.all([
api.getCourse(courseId),
api.getParticipants(courseId),
api.getSessions(courseId),
]);
if (!course) return <div>Kurs nicht gefunden</div>;
const c = course as Record<string, unknown>;
return (
<CmsPageShell account={account} title={String(c.name)}>
<div className="flex w-full flex-col gap-6">
{/* Summary Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardContent className="flex items-center gap-3 p-4">
<GraduationCap className="h-5 w-5 text-primary" />
<div>
<p className="text-xs text-muted-foreground">Name</p>
<p className="font-semibold">{String(c.name)}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Clock className="h-5 w-5 text-primary" />
<div>
<p className="text-xs text-muted-foreground">Status</p>
<Badge variant={STATUS_VARIANT[String(c.status)] ?? 'secondary'}>
{STATUS_LABEL[String(c.status)] ?? String(c.status)}
</Badge>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<User className="h-5 w-5 text-primary" />
<div>
<p className="text-xs text-muted-foreground">Dozent</p>
<p className="font-semibold">{String(c.instructor_id ?? '—')}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Calendar className="h-5 w-5 text-primary" />
<div>
<p className="text-xs text-muted-foreground">Beginn Ende</p>
<p className="font-semibold">
{c.start_date ? new Date(String(c.start_date)).toLocaleDateString('de-DE') : '—'}
{' '}
{c.end_date ? new Date(String(c.end_date)).toLocaleDateString('de-DE') : '—'}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Euro className="h-5 w-5 text-primary" />
<div>
<p className="text-xs text-muted-foreground">Gebühr</p>
<p className="font-semibold">
{c.fee != null ? `${Number(c.fee).toFixed(2)}` : '—'}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Users className="h-5 w-5 text-primary" />
<div>
<p className="text-xs text-muted-foreground">Teilnehmer</p>
<p className="font-semibold">
{participants.length} / {String(c.capacity ?? '∞')}
</p>
</div>
</CardContent>
</Card>
</div>
{/* Teilnehmer Section */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Teilnehmer</CardTitle>
<Link href={`/home/${account}/courses/${courseId}/participants`}>
<Button variant="outline" size="sm">Alle anzeigen</Button>
</Link>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">E-Mail</th>
<th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-left font-medium">Datum</th>
</tr>
</thead>
<tbody>
{participants.length === 0 ? (
<tr><td colSpan={4} className="p-6 text-center text-muted-foreground">Keine Teilnehmer</td></tr>
) : participants.map((p: Record<string, unknown>) => (
<tr key={String(p.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-medium">{String(p.last_name ?? '')}, {String(p.first_name ?? '')}</td>
<td className="p-3">{String(p.email ?? '—')}</td>
<td className="p-3"><Badge variant="outline">{String(p.status ?? '—')}</Badge></td>
<td className="p-3">{p.enrolled_at ? new Date(String(p.enrolled_at)).toLocaleDateString('de-DE') : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Termine Section */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Termine</CardTitle>
<Link href={`/home/${account}/courses/${courseId}/attendance`}>
<Button variant="outline" size="sm">Anwesenheit</Button>
</Link>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Datum</th>
<th className="p-3 text-left font-medium">Beginn</th>
<th className="p-3 text-left font-medium">Ende</th>
<th className="p-3 text-left font-medium">Abgesagt?</th>
</tr>
</thead>
<tbody>
{sessions.length === 0 ? (
<tr><td colSpan={4} className="p-6 text-center text-muted-foreground">Keine Termine</td></tr>
) : sessions.map((s: Record<string, unknown>) => (
<tr key={String(s.id)} className="border-b hover:bg-muted/30">
<td className="p-3">{s.session_date ? new Date(String(s.session_date)).toLocaleDateString('de-DE') : '—'}</td>
<td className="p-3">{String(s.start_time ?? '—')}</td>
<td className="p-3">{String(s.end_time ?? '—')}</td>
<td className="p-3">{s.cancelled ? <Badge variant="destructive">Ja</Badge> : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,114 @@
import Link from 'next/link';
import { Plus, Users } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
interface PageProps {
params: Promise<{ account: string; courseId: string }>;
}
const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'outline' | 'destructive'> = {
enrolled: 'default',
waitlisted: 'secondary',
cancelled: 'destructive',
completed: 'outline',
};
const STATUS_LABEL: Record<string, string> = {
enrolled: 'Angemeldet',
waitlisted: 'Warteliste',
cancelled: 'Abgemeldet',
completed: 'Abgeschlossen',
};
export default async function ParticipantsPage({ params }: PageProps) {
const { account, courseId } = await params;
const client = getSupabaseServerClient();
const api = createCourseManagementApi(client);
const [course, participants] = await Promise.all([
api.getCourse(courseId),
api.getParticipants(courseId),
]);
if (!course) return <div>Kurs nicht gefunden</div>;
return (
<CmsPageShell account={account} title="Teilnehmer">
<div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Teilnehmer</h1>
<p className="text-muted-foreground">
{String((course as Record<string, unknown>).name)} {participants.length} Teilnehmer
</p>
</div>
<Button>
<Plus className="mr-2 h-4 w-4" />
Teilnehmer anmelden
</Button>
</div>
{participants.length === 0 ? (
<EmptyState
icon={<Users className="h-8 w-8" />}
title="Keine Teilnehmer"
description="Melden Sie den ersten Teilnehmer für diesen Kurs an."
actionLabel="Teilnehmer anmelden"
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Teilnehmer ({participants.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">E-Mail</th>
<th className="p-3 text-left font-medium">Telefon</th>
<th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-left font-medium">Anmeldedatum</th>
</tr>
</thead>
<tbody>
{participants.map((p: Record<string, unknown>) => (
<tr key={String(p.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-medium">
{String(p.last_name ?? '')}, {String(p.first_name ?? '')}
</td>
<td className="p-3">{String(p.email ?? '—')}</td>
<td className="p-3">{String(p.phone ?? '—')}</td>
<td className="p-3">
<Badge variant={STATUS_VARIANT[String(p.status)] ?? 'secondary'}>
{STATUS_LABEL[String(p.status)] ?? String(p.status)}
</Badge>
</td>
<td className="p-3">
{p.enrolled_at
? new Date(String(p.enrolled_at)).toLocaleDateString('de-DE')
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,248 @@
import Link from 'next/link';
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;
}
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
const MONTH_NAMES = [
'Januar',
'Februar',
'März',
'April',
'Mai',
'Juni',
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember',
];
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function getFirstWeekday(year: number, month: number): number {
const day = new Date(year, month, 1).getDay();
return day === 0 ? 6 : day - 1;
}
export default async function CourseCalendarPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createCourseManagementApi(client);
const courses = await api.listCourses(acct.id, { page: 1, pageSize: 100 });
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const daysInMonth = getDaysInMonth(year, month);
const firstWeekday = getFirstWeekday(year, month);
// Build set of days that have running courses
const courseDates = new Set<number>();
for (const course of courses.data) {
const c = course as Record<string, unknown>;
if (c.status === 'cancelled') continue;
const startDate = c.start_date ? new Date(String(c.start_date)) : null;
const endDate = c.end_date ? new Date(String(c.end_date)) : null;
if (!startDate) continue;
const courseStart = startDate;
const courseEnd = endDate ?? startDate;
for (let d = 1; d <= daysInMonth; d++) {
const dayDate = new Date(year, month, d);
if (dayDate >= courseStart && dayDate <= courseEnd) {
courseDates.add(d);
}
}
}
// Build calendar grid
const cells: Array<{ day: number | null; hasCourse: boolean; isToday: boolean }> = [];
for (let i = 0; i < firstWeekday; i++) {
cells.push({ day: null, hasCourse: false, isToday: false });
}
for (let d = 1; d <= daysInMonth; d++) {
cells.push({
day: d,
hasCourse: courseDates.has(d),
isToday: d === now.getDate() && month === now.getMonth() && year === now.getFullYear(),
});
}
while (cells.length % 7 !== 0) {
cells.push({ day: null, hasCourse: false, isToday: false });
}
const activeCourses = courses.data.filter(
(c: Record<string, unknown>) =>
c.status === 'open' || c.status === 'running',
);
return (
<CmsPageShell account={account} title="Kurskalender">
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/home/${account}/courses`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Kurskalender</h1>
<p className="text-muted-foreground">
Kurstermine im Überblick
</p>
</div>
</div>
</div>
{/* Month Calendar */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<Button variant="ghost" size="icon" disabled>
<ChevronLeft className="h-4 w-4" />
</Button>
<CardTitle>
{MONTH_NAMES[month]} {year}
</CardTitle>
<Button variant="ghost" size="icon" disabled>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{/* Weekday Header */}
<div className="grid grid-cols-7 gap-1 mb-1">
{WEEKDAYS.map((day) => (
<div
key={day}
className="text-center text-xs font-medium text-muted-foreground py-2"
>
{day}
</div>
))}
</div>
{/* Calendar Grid */}
<div className="grid grid-cols-7 gap-1">
{cells.map((cell, idx) => (
<div
key={idx}
className={`relative flex h-12 items-center justify-center rounded-md text-sm transition-colors ${
cell.day === null
? 'bg-transparent'
: cell.hasCourse
? 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 font-semibold'
: 'bg-muted/30 hover:bg-muted/50'
} ${cell.isToday ? 'ring-2 ring-primary ring-offset-1' : ''}`}
>
{cell.day !== null && (
<>
<span>{cell.day}</span>
{cell.hasCourse && (
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 h-1.5 w-1.5 rounded-full bg-emerald-500" />
)}
</>
)}
</div>
))}
</div>
{/* Legend */}
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm bg-emerald-500/15" />
Kurstag
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm bg-muted/30" />
Frei
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm ring-2 ring-primary" />
Heute
</div>
</div>
</CardContent>
</Card>
{/* Active Courses this Month */}
<Card>
<CardHeader>
<CardTitle>Aktive Kurse ({activeCourses.length})</CardTitle>
</CardHeader>
<CardContent>
{activeCourses.length === 0 ? (
<p className="text-sm text-muted-foreground">
Keine aktiven Kurse in diesem Monat.
</p>
) : (
<div className="space-y-3">
{activeCourses.map((course: Record<string, unknown>) => (
<div
key={String(course.id)}
className="flex items-center justify-between rounded-md border p-3"
>
<div>
<Link
href={`/home/${account}/courses/${String(course.id)}`}
className="font-medium hover:underline"
>
{String(course.name)}
</Link>
<p className="text-xs text-muted-foreground">
{course.start_date
? new Date(String(course.start_date)).toLocaleDateString('de-DE')
: '—'}{' '}
{' '}
{course.end_date
? new Date(String(course.end_date)).toLocaleDateString('de-DE')
: '—'}
</p>
</div>
<Badge variant={String(course.status) === 'running' ? 'info' : 'default'}>
{String(course.status) === 'running' ? 'Laufend' : 'Offen'}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,86 @@
import { FolderTree, Plus } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function CategoriesPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createCourseManagementApi(client);
const categories = await api.listCategories(acct.id);
return (
<CmsPageShell account={account} title="Kategorien">
<div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Kategorien</h1>
<p className="text-muted-foreground">Kurskategorien verwalten</p>
</div>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neue Kategorie
</Button>
</div>
{categories.length === 0 ? (
<EmptyState
icon={<FolderTree className="h-8 w-8" />}
title="Keine Kategorien vorhanden"
description="Erstellen Sie Ihre erste Kurskategorie."
actionLabel="Neue Kategorie"
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Kategorien ({categories.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">Beschreibung</th>
<th className="p-3 text-left font-medium">Übergeordnet</th>
</tr>
</thead>
<tbody>
{categories.map((cat: Record<string, unknown>) => (
<tr key={String(cat.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-medium">{String(cat.name)}</td>
<td className="p-3 text-muted-foreground">
{String(cat.description ?? '—')}
</td>
<td className="p-3">{String(cat.parent_id ?? '—')}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,94 @@
import { GraduationCap, Plus } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function InstructorsPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createCourseManagementApi(client);
const instructors = await api.listInstructors(acct.id);
return (
<CmsPageShell account={account} title="Dozenten">
<div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Dozenten</h1>
<p className="text-muted-foreground">Dozentenpool verwalten</p>
</div>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neuer Dozent
</Button>
</div>
{instructors.length === 0 ? (
<EmptyState
icon={<GraduationCap className="h-8 w-8" />}
title="Keine Dozenten vorhanden"
description="Fügen Sie Ihren ersten Dozenten hinzu."
actionLabel="Neuer Dozent"
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Dozenten ({instructors.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">E-Mail</th>
<th className="p-3 text-left font-medium">Telefon</th>
<th className="p-3 text-left font-medium">Qualifikation</th>
<th className="p-3 text-right font-medium">Stundensatz</th>
</tr>
</thead>
<tbody>
{instructors.map((inst: Record<string, unknown>) => (
<tr key={String(inst.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-medium">
{String(inst.last_name ?? '')}, {String(inst.first_name ?? '')}
</td>
<td className="p-3">{String(inst.email ?? '—')}</td>
<td className="p-3">{String(inst.phone ?? '—')}</td>
<td className="p-3">{String(inst.qualification ?? '—')}</td>
<td className="p-3 text-right">
{inst.hourly_rate != null
? `${Number(inst.hourly_rate).toFixed(2)}`
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,91 @@
import { MapPin, Plus } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function LocationsPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createCourseManagementApi(client);
const locations = await api.listLocations(acct.id);
return (
<CmsPageShell account={account} title="Orte">
<div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Orte</h1>
<p className="text-muted-foreground">Kurs- und Veranstaltungsorte verwalten</p>
</div>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neuer Ort
</Button>
</div>
{locations.length === 0 ? (
<EmptyState
icon={<MapPin className="h-8 w-8" />}
title="Keine Orte vorhanden"
description="Fügen Sie Ihren ersten Veranstaltungsort hinzu."
actionLabel="Neuer Ort"
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Orte ({locations.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">Adresse</th>
<th className="p-3 text-left font-medium">Raum</th>
<th className="p-3 text-right font-medium">Kapazität</th>
</tr>
</thead>
<tbody>
{locations.map((loc: Record<string, unknown>) => (
<tr key={String(loc.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-medium">{String(loc.name)}</td>
<td className="p-3">
{[loc.street, loc.postal_code, loc.city]
.filter(Boolean)
.map(String)
.join(', ') || '—'}
</td>
<td className="p-3">{String(loc.room ?? '—')}</td>
<td className="p-3 text-right">{String(loc.capacity ?? '—')}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,191 @@
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Textarea } from '@kit/ui/textarea';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function NewCoursePage({ params }: PageProps) {
const { account } = await params;
return (
<CmsPageShell account={account} title="Neuer Kurs">
<div className="flex w-full max-w-3xl flex-col gap-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href={`/home/${account}/courses`}>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
Zurück
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Neuer Kurs</h1>
<p className="text-muted-foreground">Kurs anlegen</p>
</div>
</div>
<form className="flex flex-col gap-6">
{/* Grunddaten */}
<Card>
<CardHeader>
<CardTitle>Grunddaten</CardTitle>
<CardDescription>
Allgemeine Informationen zum Kurs
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="courseNumber">Kursnummer</Label>
<Input
id="courseNumber"
name="courseNumber"
placeholder="z.B. K-2025-001"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="name">Kursname</Label>
<Input
id="name"
name="name"
placeholder="z.B. Töpfern für Anfänger"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Beschreibung</Label>
<Textarea
id="description"
name="description"
placeholder="Kursbeschreibung…"
rows={4}
/>
</div>
</CardContent>
</Card>
{/* Zeitplan */}
<Card>
<CardHeader>
<CardTitle>Zeitplan</CardTitle>
<CardDescription>Beginn und Ende des Kurses</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="startDate">Beginn</Label>
<Input
id="startDate"
name="startDate"
type="date"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="endDate">Ende</Label>
<Input id="endDate" name="endDate" type="date" />
</div>
</CardContent>
</Card>
{/* Kapazität */}
<Card>
<CardHeader>
<CardTitle>Kapazität</CardTitle>
<CardDescription>
Teilnehmer und Gebühren
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="capacity">Max. Teilnehmer</Label>
<Input
id="capacity"
name="capacity"
type="number"
min={1}
placeholder="20"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="minParticipants">Min. Teilnehmer</Label>
<Input
id="minParticipants"
name="minParticipants"
type="number"
min={0}
placeholder="5"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="fee">Gebühr ()</Label>
<Input
id="fee"
name="fee"
type="number"
min={0}
step="0.01"
placeholder="0.00"
/>
</div>
</CardContent>
</Card>
{/* Zuordnung */}
<Card>
<CardHeader>
<CardTitle>Zuordnung</CardTitle>
<CardDescription>Status des Kurses</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="status">Status</Label>
<select
id="status"
name="status"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
defaultValue="planned"
>
<option value="planned">Geplant</option>
<option value="open">Offen</option>
<option value="running">Laufend</option>
<option value="completed">Abgeschlossen</option>
<option value="cancelled">Abgesagt</option>
</select>
</div>
</CardContent>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-3">
<Link href={`/home/${account}/courses`}>
<Button variant="outline" type="button">
Abbrechen
</Button>
</Link>
<Button type="submit">Kurs erstellen</Button>
</div>
</form>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,180 @@
import Link from 'next/link';
import { GraduationCap, Plus, Users, Calendar, Euro } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card';
interface PageProps {
params: Promise<{ account: string }>;
}
const STATUS_BADGE_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
planned: 'secondary',
open: 'default',
running: 'info',
completed: 'outline',
cancelled: 'destructive',
};
const STATUS_LABEL: Record<string, string> = {
planned: 'Geplant',
open: 'Offen',
running: 'Laufend',
completed: 'Abgeschlossen',
cancelled: 'Abgesagt',
};
export default async function CoursesPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createCourseManagementApi(client);
const [courses, stats] = await Promise.all([
api.listCourses(acct.id, { page: 1 }),
api.getStatistics(acct.id),
]);
return (
<CmsPageShell account={account} title="Kurse">
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Kurse</h1>
<p className="text-muted-foreground">
Kursangebot verwalten
</p>
</div>
<Link href={`/home/${account}/courses/new`}>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neuer Kurs
</Button>
</Link>
</div>
{/* Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Gesamt"
value={stats.totalCourses}
icon={<GraduationCap className="h-5 w-5" />}
/>
<StatsCard
title="Aktiv"
value={stats.openCourses}
icon={<Calendar className="h-5 w-5" />}
/>
<StatsCard
title="Abgeschlossen"
value={stats.completedCourses}
icon={<GraduationCap className="h-5 w-5" />}
/>
<StatsCard
title="Teilnehmer"
value={stats.totalParticipants}
icon={<Users className="h-5 w-5" />}
/>
</div>
{/* Table or Empty State */}
{courses.data.length === 0 ? (
<EmptyState
icon={<GraduationCap className="h-8 w-8" />}
title="Keine Kurse vorhanden"
description="Erstellen Sie Ihren ersten Kurs, um loszulegen."
actionLabel="Neuer Kurs"
actionHref={`/home/${account}/courses/new`}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Kurse ({courses.total})</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Kursnr.</th>
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">Beginn</th>
<th className="p-3 text-left font-medium">Ende</th>
<th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-right font-medium">Teilnehmer</th>
<th className="p-3 text-right font-medium">Gebühr</th>
</tr>
</thead>
<tbody>
{courses.data.map((course: Record<string, unknown>) => (
<tr key={String(course.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-mono text-xs">
{String(course.course_number ?? '—')}
</td>
<td className="p-3 font-medium">
<Link
href={`/home/${account}/courses/${String(course.id)}`}
className="hover:underline"
>
{String(course.name)}
</Link>
</td>
<td className="p-3">
{course.start_date
? new Date(String(course.start_date)).toLocaleDateString('de-DE')
: '—'}
</td>
<td className="p-3">
{course.end_date
? new Date(String(course.end_date)).toLocaleDateString('de-DE')
: '—'}
</td>
<td className="p-3">
<Badge
variant={STATUS_BADGE_VARIANT[String(course.status)] ?? 'secondary'}
>
{STATUS_LABEL[String(course.status)] ?? String(course.status)}
</Badge>
</td>
<td className="p-3 text-right">
{String(course.capacity ?? '—')}
</td>
<td className="p-3 text-right">
{course.fee != null
? `${Number(course.fee).toFixed(2)}`
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,99 @@
import {
GraduationCap,
Users,
Calendar,
TrendingUp,
BarChart3,
} from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { StatsCard } from '~/components/stats-card';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function CourseStatisticsPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createCourseManagementApi(client);
const stats = await api.getStatistics(acct.id);
return (
<CmsPageShell account={account} title="Kurs-Statistiken">
<div className="flex w-full flex-col gap-6">
<div>
<h1 className="text-2xl font-bold">Statistiken</h1>
<p className="text-muted-foreground">Übersicht über das Kursangebot</p>
</div>
{/* Stat Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Kurse gesamt"
value={stats.totalCourses}
icon={<GraduationCap className="h-5 w-5" />}
/>
<StatsCard
title="Aktive Kurse"
value={stats.openCourses}
icon={<Calendar className="h-5 w-5" />}
/>
<StatsCard
title="Teilnehmer gesamt"
value={stats.totalParticipants}
icon={<Users className="h-5 w-5" />}
/>
<StatsCard
title="Abgeschlossen"
value={stats.completedCourses}
icon={<TrendingUp className="h-5 w-5" />}
/>
</div>
{/* Chart Placeholder */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Kursauslastung
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex h-64 items-center justify-center rounded-md border border-dashed text-muted-foreground">
Diagramm wird hier angezeigt
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Anmeldungen pro Monat
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex h-64 items-center justify-center rounded-md border border-dashed text-muted-foreground">
Diagramm wird hier angezeigt
</div>
</CardContent>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,157 @@
import Link from 'next/link';
import { ArrowLeft, FileDown } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;
searchParams: Promise<{ type?: string }>;
}
const DOCUMENT_LABELS: Record<string, string> = {
'member-card': 'Mitgliedsausweis',
invoice: 'Rechnung',
labels: 'Etiketten',
report: 'Bericht',
letter: 'Brief',
certificate: 'Zertifikat',
};
export default async function GenerateDocumentPage({
params,
searchParams,
}: PageProps) {
const { account } = await params;
const { type } = await searchParams;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const selectedType = type ?? 'member-card';
const selectedLabel = DOCUMENT_LABELS[selectedType] ?? 'Dokument';
return (
<CmsPageShell account={account} title="Dokument generieren">
<div className="flex w-full flex-col gap-6">
{/* Back link */}
<div>
<Link
href={`/home/${account}/documents`}
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Zurück zu Dokumente
</Link>
</div>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>{selectedLabel} generieren</CardTitle>
<CardDescription>
Wählen Sie den Dokumenttyp und die gewünschten Optionen.
</CardDescription>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-5">
{/* Document Type */}
<div className="flex flex-col gap-2">
<Label htmlFor="documentType">Dokumenttyp</Label>
<select
id="documentType"
name="documentType"
defaultValue={selectedType}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<option value="member-card">Mitgliedsausweis</option>
<option value="invoice">Rechnung</option>
<option value="labels">Etiketten</option>
<option value="report">Bericht</option>
<option value="letter">Brief</option>
<option value="certificate">Zertifikat</option>
</select>
</div>
{/* Title */}
<div className="flex flex-col gap-2">
<Label htmlFor="title">Titel / Bezeichnung</Label>
<Input
id="title"
name="title"
placeholder={`z.B. ${selectedLabel} für Max Mustermann`}
required
/>
</div>
{/* Format */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="format">Format</Label>
<select
id="format"
name="format"
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<option value="A4">A4</option>
<option value="A5">A5</option>
<option value="letter">Letter</option>
</select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="orientation">Ausrichtung</Label>
<select
id="orientation"
name="orientation"
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<option value="portrait">Hochformat</option>
<option value="landscape">Querformat</option>
</select>
</div>
</div>
{/* Info */}
<div className="rounded-md bg-muted/50 p-4 text-sm text-muted-foreground">
<p>
<strong>Hinweis:</strong> Die Dokumentgenerierung verwendet
Ihre gespeicherten Vorlagen. Stellen Sie sicher, dass eine
passende Vorlage für den gewählten Dokumenttyp existiert.
</p>
</div>
</form>
</CardContent>
<CardFooter className="flex justify-between">
<Link href={`/home/${account}/documents`}>
<Button variant="outline">Abbrechen</Button>
</Link>
<Button type="submit">
<FileDown className="mr-2 h-4 w-4" />
Generieren
</Button>
</CardFooter>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,137 @@
import Link from 'next/link';
import {
CreditCard,
FileText,
Tag,
BarChart3,
Mail,
Award,
} from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;
}
const DOCUMENT_TYPES = [
{
id: 'member-card',
title: 'Mitgliedsausweis',
description:
'Mitgliedsausweise mit Foto, Name und Mitgliedsnummer generieren.',
icon: CreditCard,
color: 'text-blue-600 bg-blue-50',
},
{
id: 'invoice',
title: 'Rechnung',
description:
'Professionelle Rechnungen im PDF-Format mit Logo und Positionen.',
icon: FileText,
color: 'text-green-600 bg-green-50',
},
{
id: 'labels',
title: 'Etiketten',
description:
'Adressetiketten für Serienbriefe im Avery-Format drucken.',
icon: Tag,
color: 'text-orange-600 bg-orange-50',
},
{
id: 'report',
title: 'Bericht',
description:
'Statistische Auswertungen und Berichte als PDF oder Excel.',
icon: BarChart3,
color: 'text-purple-600 bg-purple-50',
},
{
id: 'letter',
title: 'Brief',
description:
'Serienbriefe mit personalisierten Platzhaltern erstellen.',
icon: Mail,
color: 'text-rose-600 bg-rose-50',
},
{
id: 'certificate',
title: 'Zertifikat',
description:
'Teilnahmebescheinigungen und Zertifikate mit Unterschrift.',
icon: Award,
color: 'text-amber-600 bg-amber-50',
},
] as const;
export default async function DocumentsPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
return (
<CmsPageShell account={account} title="Dokumente">
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Dokumente</h1>
<p className="text-muted-foreground">
Dokumente erstellen und verwalten
</p>
</div>
<div className="flex gap-2">
<Link href={`/home/${account}/documents/templates`}>
<Button variant="outline">Vorlagen</Button>
</Link>
</div>
</div>
{/* Document Type Grid */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{DOCUMENT_TYPES.map((docType) => {
const Icon = docType.icon;
return (
<Card key={docType.id} className="flex flex-col">
<CardHeader className="flex flex-row items-start gap-4 space-y-0">
<div className={`rounded-lg p-3 ${docType.color}`}>
<Icon className="h-6 w-6" />
</div>
<div className="flex-1">
<CardTitle className="text-base">{docType.title}</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col justify-between gap-4">
<p className="text-sm text-muted-foreground">
{docType.description}
</p>
<Link
href={`/home/${account}/documents/generate?type=${docType.id}`}
>
<Button variant="outline" size="sm" className="w-full">
Erstellen
</Button>
</Link>
</CardContent>
</Card>
);
})}
</div>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,101 @@
import Link from 'next/link';
import { FileText, Plus } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function DocumentTemplatesPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
// Document templates are stored locally for now — placeholder for future DB integration
const templates: Array<{
id: string;
name: string;
type: string;
description: string;
}> = [];
return (
<CmsPageShell account={account} title="Dokumentvorlagen">
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Dokumentvorlagen</h1>
<p className="text-muted-foreground">
Vorlagen für Mitgliedsausweise, Rechnungen, Etiketten und mehr
</p>
</div>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neue Vorlage
</Button>
</div>
{/* Table or Empty State */}
{templates.length === 0 ? (
<EmptyState
icon={<FileText className="h-8 w-8" />}
title="Keine Vorlagen vorhanden"
description="Erstellen Sie Ihre erste Dokumentvorlage, um Mitgliedsausweise, Rechnungen und mehr zu generieren."
actionLabel="Neue Vorlage"
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Vorlagen ({templates.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">Typ</th>
<th className="p-3 text-left font-medium">
Beschreibung
</th>
</tr>
</thead>
<tbody>
{templates.map((template) => (
<tr
key={template.id}
className="border-b hover:bg-muted/30"
>
<td className="p-3 font-medium">{template.name}</td>
<td className="p-3">{template.type}</td>
<td className="p-3 text-muted-foreground">
{template.description}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,182 @@
import Link from 'next/link';
import {
CalendarDays,
MapPin,
Users,
Euro,
Clock,
UserPlus,
} from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createEventManagementApi } from '@kit/event-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
interface PageProps {
params: Promise<{ account: string; eventId: string }>;
}
const STATUS_LABEL: Record<string, string> = {
draft: 'Entwurf',
published: 'Veröffentlicht',
registration_open: 'Anmeldung offen',
registration_closed: 'Anmeldung geschlossen',
cancelled: 'Abgesagt',
completed: 'Abgeschlossen',
};
const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'outline' | 'destructive'> = {
draft: 'secondary',
published: 'default',
registration_open: 'info',
registration_closed: 'outline',
cancelled: 'destructive',
completed: 'outline',
};
export default async function EventDetailPage({ params }: PageProps) {
const { account, eventId } = await params;
const client = getSupabaseServerClient();
const api = createEventManagementApi(client);
const [event, registrations] = await Promise.all([
api.getEvent(eventId),
api.getRegistrations(eventId),
]);
if (!event) return <div>Veranstaltung nicht gefunden</div>;
const e = event as Record<string, unknown>;
return (
<CmsPageShell account={account} title={String(e.name)}>
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{String(e.name)}</h1>
<Badge variant={STATUS_VARIANT[String(e.status)] ?? 'secondary'} className="mt-1">
{STATUS_LABEL[String(e.status)] ?? String(e.status)}
</Badge>
</div>
<Button>
<UserPlus className="mr-2 h-4 w-4" />
Anmelden
</Button>
</div>
{/* Detail Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardContent className="flex items-center gap-3 p-4">
<CalendarDays className="h-5 w-5 text-primary" />
<div>
<p className="text-xs text-muted-foreground">Datum</p>
<p className="font-semibold">
{e.event_date
? new Date(String(e.event_date)).toLocaleDateString('de-DE')
: '—'}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Clock className="h-5 w-5 text-primary" />
<div>
<p className="text-xs text-muted-foreground">Uhrzeit</p>
<p className="font-semibold">
{String(e.start_time ?? '—')} {String(e.end_time ?? '—')}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<MapPin className="h-5 w-5 text-primary" />
<div>
<p className="text-xs text-muted-foreground">Ort</p>
<p className="font-semibold">{String(e.location ?? '—')}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Users className="h-5 w-5 text-primary" />
<div>
<p className="text-xs text-muted-foreground">Anmeldungen</p>
<p className="font-semibold">
{registrations.length} / {String(e.capacity ?? '∞')}
</p>
</div>
</CardContent>
</Card>
</div>
{/* Description */}
{e.description ? (
<Card>
<CardHeader>
<CardTitle>Beschreibung</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{String(e.description)}
</p>
</CardContent>
</Card>
) : null}
{/* Registrations Table */}
<Card>
<CardHeader>
<CardTitle>Anmeldungen ({registrations.length})</CardTitle>
</CardHeader>
<CardContent>
{registrations.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
Noch keine Anmeldungen
</p>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">E-Mail</th>
<th className="p-3 text-left font-medium">Elternteil</th>
<th className="p-3 text-left font-medium">Datum</th>
</tr>
</thead>
<tbody>
{registrations.map((reg: Record<string, unknown>) => (
<tr key={String(reg.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-medium">
{String(reg.last_name ?? '')}, {String(reg.first_name ?? '')}
</td>
<td className="p-3">{String(reg.email ?? '—')}</td>
<td className="p-3">{String(reg.parent_name ?? '—')}</td>
<td className="p-3">
{reg.created_at
? new Date(String(reg.created_at)).toLocaleDateString('de-DE')
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,100 @@
import { Ticket, Plus } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createEventManagementApi } from '@kit/event-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function HolidayPassesPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createEventManagementApi(client);
const passes = await api.listHolidayPasses(acct.id);
return (
<CmsPageShell account={account} title="Ferienpässe">
<div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Ferienpässe</h1>
<p className="text-muted-foreground">Ferienpässe und Ferienprogramme verwalten</p>
</div>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neuer Ferienpass
</Button>
</div>
{passes.length === 0 ? (
<EmptyState
icon={<Ticket className="h-8 w-8" />}
title="Keine Ferienpässe vorhanden"
description="Erstellen Sie Ihren ersten Ferienpass."
actionLabel="Neuer Ferienpass"
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Ferienpässe ({passes.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">Jahr</th>
<th className="p-3 text-right font-medium">Preis</th>
<th className="p-3 text-left font-medium">Gültig von</th>
<th className="p-3 text-left font-medium">Gültig bis</th>
</tr>
</thead>
<tbody>
{passes.map((pass: Record<string, unknown>) => (
<tr key={String(pass.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-medium">{String(pass.name)}</td>
<td className="p-3">{String(pass.year ?? '—')}</td>
<td className="p-3 text-right">
{pass.price != null
? `${Number(pass.price).toFixed(2)}`
: '—'}
</td>
<td className="p-3">
{pass.valid_from
? new Date(String(pass.valid_from)).toLocaleDateString('de-DE')
: '—'}
</td>
<td className="p-3">
{pass.valid_until
? new Date(String(pass.valid_until)).toLocaleDateString('de-DE')
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,301 @@
import Link from 'next/link';
import {
ArrowLeft,
CalendarDays,
Clock,
MapPin,
Phone,
Users,
} from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function NewEventPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
return (
<CmsPageShell account={account} title="Neue Veranstaltung">
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href={`/home/${account}/events`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Neue Veranstaltung</h1>
<p className="text-muted-foreground">
Veranstaltung oder Ferienprogramm anlegen
</p>
</div>
</div>
<form className="flex flex-col gap-6">
{/* Grunddaten */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarDays className="h-5 w-5" />
Grunddaten
</CardTitle>
<CardDescription>
Name und Beschreibung der Veranstaltung
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label htmlFor="name" className="text-sm font-medium">
Name
</label>
<input
id="name"
name="name"
type="text"
required
placeholder="z.B. Sommerfest 2025"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="description" className="text-sm font-medium">
Beschreibung
</label>
<textarea
id="description"
name="description"
rows={4}
placeholder="Beschreiben Sie die Veranstaltung…"
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</CardContent>
</Card>
{/* Datum & Ort */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Datum &amp; Ort
</CardTitle>
<CardDescription>
Zeitraum und Veranstaltungsort
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="flex flex-col gap-1.5">
<label htmlFor="event_date" className="text-sm font-medium">
Veranstaltungsdatum
</label>
<input
id="event_date"
name="event_date"
type="date"
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="event_time" className="text-sm font-medium">
Uhrzeit
</label>
<input
id="event_time"
name="event_time"
type="time"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="end_date" className="text-sm font-medium">
Enddatum
</label>
<input
id="end_date"
name="end_date"
type="date"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="location" className="text-sm font-medium">
Ort
</label>
<input
id="location"
name="location"
type="text"
placeholder="z.B. Gemeindehaus, Turnhalle"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</CardContent>
</Card>
{/* Teilnehmer & Kosten */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Teilnehmer &amp; Kosten
</CardTitle>
<CardDescription>
Kapazität, Alter und Teilnahmegebühr
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="flex flex-col gap-1.5">
<label htmlFor="capacity" className="text-sm font-medium">
Kapazität
</label>
<input
id="capacity"
name="capacity"
type="number"
min={1}
placeholder="Max. Teilnehmer"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="min_age" className="text-sm font-medium">
Mindestalter
</label>
<input
id="min_age"
name="min_age"
type="number"
min={0}
placeholder="z.B. 6"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="max_age" className="text-sm font-medium">
Höchstalter
</label>
<input
id="max_age"
name="max_age"
type="number"
min={0}
placeholder="z.B. 16"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="fee" className="text-sm font-medium">
Gebühr ()
</label>
<input
id="fee"
name="fee"
type="number"
step="0.01"
min={0}
placeholder="0.00"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</CardContent>
</Card>
{/* Kontakt */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Phone className="h-5 w-5" />
Kontakt
</CardTitle>
<CardDescription>
Ansprechpartner für die Veranstaltung
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="flex flex-col gap-1.5">
<label htmlFor="contact_name" className="text-sm font-medium">
Name
</label>
<input
id="contact_name"
name="contact_name"
type="text"
placeholder="Vorname Nachname"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="contact_email" className="text-sm font-medium">
E-Mail
</label>
<input
id="contact_email"
name="contact_email"
type="email"
placeholder="name@example.de"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="contact_phone" className="text-sm font-medium">
Telefon
</label>
<input
id="contact_phone"
name="contact_phone"
type="tel"
placeholder="+49 …"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</CardContent>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-3">
<Link href={`/home/${account}/events`}>
<Button type="button" variant="outline">
Abbrechen
</Button>
</Link>
<Button type="submit">Veranstaltung erstellen</Button>
</div>
</form>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,180 @@
import Link from 'next/link';
import { CalendarDays, MapPin, Plus, Users } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createEventManagementApi } from '@kit/event-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card';
interface PageProps {
params: Promise<{ account: string }>;
}
const STATUS_BADGE_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
draft: 'secondary',
published: 'default',
registration_open: 'info',
registration_closed: 'outline',
cancelled: 'destructive',
completed: 'outline',
};
const STATUS_LABEL: Record<string, string> = {
draft: 'Entwurf',
published: 'Veröffentlicht',
registration_open: 'Anmeldung offen',
registration_closed: 'Anmeldung geschlossen',
cancelled: 'Abgesagt',
completed: 'Abgeschlossen',
};
export default async function EventsPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createEventManagementApi(client);
const events = await api.listEvents(acct.id, { page: 1 });
return (
<CmsPageShell account={account} title="Veranstaltungen">
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Veranstaltungen</h1>
<p className="text-muted-foreground">
Veranstaltungen und Ferienprogramme
</p>
</div>
<Link href={`/home/${account}/events/new`}>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neue Veranstaltung
</Button>
</Link>
</div>
{/* Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatsCard
title="Veranstaltungen"
value={events.total}
icon={<CalendarDays className="h-5 w-5" />}
/>
<StatsCard
title="Orte"
value={
new Set(
events.data
.map((e: Record<string, unknown>) => e.location)
.filter(Boolean),
).size
}
icon={<MapPin className="h-5 w-5" />}
/>
<StatsCard
title="Kapazität gesamt"
value={events.data.reduce(
(sum: number, e: Record<string, unknown>) =>
sum + (Number(e.capacity) || 0),
0,
)}
icon={<Users className="h-5 w-5" />}
/>
</div>
{/* Table or Empty State */}
{events.data.length === 0 ? (
<EmptyState
icon={<CalendarDays className="h-8 w-8" />}
title="Keine Veranstaltungen vorhanden"
description="Erstellen Sie Ihre erste Veranstaltung, um loszulegen."
actionLabel="Neue Veranstaltung"
actionHref={`/home/${account}/events/new`}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Veranstaltungen ({events.total})</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">Datum</th>
<th className="p-3 text-left font-medium">Ort</th>
<th className="p-3 text-right font-medium">Kapazität</th>
<th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-right font-medium">Anmeldungen</th>
</tr>
</thead>
<tbody>
{events.data.map((event: Record<string, unknown>) => (
<tr
key={String(event.id)}
className="border-b hover:bg-muted/30"
>
<td className="p-3 font-medium">
<Link
href={`/home/${account}/events/${String(event.id)}`}
className="hover:underline"
>
{String(event.name)}
</Link>
</td>
<td className="p-3">
{event.event_date
? new Date(String(event.event_date)).toLocaleDateString('de-DE')
: '—'}
</td>
<td className="p-3">
{String(event.location ?? '—')}
</td>
<td className="p-3 text-right">
{event.capacity != null
? String(event.capacity)
: '—'}
</td>
<td className="p-3">
<Badge
variant={
STATUS_BADGE_VARIANT[String(event.status)] ?? 'secondary'
}
>
{STATUS_LABEL[String(event.status)] ?? String(event.status)}
</Badge>
</td>
<td className="p-3 text-right"></td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,188 @@
import Link from 'next/link';
import { CalendarDays, ClipboardList, Users } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createEventManagementApi } from '@kit/event-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function EventRegistrationsPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createEventManagementApi(client);
const events = await api.listEvents(acct.id, { page: 1 });
// Load registrations for each event in parallel
const eventsWithRegistrations = await Promise.all(
events.data.map(async (event: Record<string, unknown>) => {
const registrations = await api.getRegistrations(String(event.id));
return {
id: String(event.id),
name: String(event.name),
eventDate: event.event_date ? String(event.event_date) : null,
status: String(event.status ?? 'draft'),
capacity: event.capacity != null ? Number(event.capacity) : null,
registrationCount: registrations.length,
};
}),
);
const totalRegistrations = eventsWithRegistrations.reduce(
(sum, e) => sum + e.registrationCount,
0,
);
const eventsWithRegs = eventsWithRegistrations.filter(
(e) => e.registrationCount > 0,
);
return (
<CmsPageShell account={account} title="Anmeldungen">
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold">Anmeldungen</h1>
<p className="text-muted-foreground">
Anmeldungen aller Veranstaltungen im Überblick
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatsCard
title="Veranstaltungen"
value={events.total}
icon={<CalendarDays className="h-5 w-5" />}
/>
<StatsCard
title="Anmeldungen gesamt"
value={totalRegistrations}
icon={<ClipboardList className="h-5 w-5" />}
/>
<StatsCard
title="Mit Anmeldungen"
value={eventsWithRegs.length}
icon={<Users className="h-5 w-5" />}
/>
</div>
{/* Registration Summary Table */}
{eventsWithRegistrations.length === 0 ? (
<EmptyState
icon={<ClipboardList className="h-8 w-8" />}
title="Keine Veranstaltungen vorhanden"
description="Erstellen Sie eine Veranstaltung, um Anmeldungen zu erhalten."
actionLabel="Neue Veranstaltung"
actionHref={`/home/${account}/events/new`}
/>
) : (
<Card>
<CardHeader>
<CardTitle>
Übersicht nach Veranstaltung ({eventsWithRegistrations.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">
Veranstaltung
</th>
<th className="p-3 text-left font-medium">Datum</th>
<th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-right font-medium">Kapazität</th>
<th className="p-3 text-right font-medium">
Anmeldungen
</th>
<th className="p-3 text-right font-medium">Auslastung</th>
</tr>
</thead>
<tbody>
{eventsWithRegistrations.map((event) => {
const utilization =
event.capacity && event.capacity > 0
? Math.round(
(event.registrationCount / event.capacity) * 100,
)
: null;
return (
<tr
key={event.id}
className="border-b hover:bg-muted/30"
>
<td className="p-3 font-medium">
<Link
href={`/home/${account}/events/${event.id}`}
className="hover:underline"
>
{event.name}
</Link>
</td>
<td className="p-3">
{event.eventDate
? new Date(event.eventDate).toLocaleDateString(
'de-DE',
)
: '—'}
</td>
<td className="p-3">
<Badge variant="outline">{event.status}</Badge>
</td>
<td className="p-3 text-right">
{event.capacity ?? '—'}
</td>
<td className="p-3 text-right font-medium">
{event.registrationCount}
</td>
<td className="p-3 text-right">
{utilization !== null ? (
<Badge
variant={
utilization >= 90
? 'destructive'
: utilization >= 70
? 'default'
: 'secondary'
}
>
{utilization}%
</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,234 @@
import Link from 'next/link';
import { ArrowLeft, Send, CheckCircle } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createFinanceApi } from '@kit/finance/api';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string; id: string }>;
}
const STATUS_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
draft: 'secondary',
sent: 'default',
paid: 'info',
overdue: 'destructive',
cancelled: 'destructive',
};
const STATUS_LABEL: Record<string, string> = {
draft: 'Entwurf',
sent: 'Versendet',
paid: 'Bezahlt',
overdue: 'Überfällig',
cancelled: 'Storniert',
};
const formatCurrency = (amount: unknown) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
Number(amount),
);
export default async function InvoiceDetailPage({ params }: PageProps) {
const { account, id } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createFinanceApi(client);
const invoice = await api.getInvoiceWithItems(id);
if (!invoice) return <div>Rechnung nicht gefunden</div>;
const status = String(invoice.status);
const items = (invoice.items ?? []) as Array<Record<string, unknown>>;
return (
<CmsPageShell account={account} title="Rechnungsdetails">
<div className="flex w-full flex-col gap-6">
{/* Back link */}
<div>
<Link
href={`/home/${account}/finance/invoices`}
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Zurück zu Rechnungen
</Link>
</div>
{/* Summary Card */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>
Rechnung {String(invoice.invoice_number ?? '')}
</CardTitle>
<Badge variant={STATUS_VARIANT[status] ?? 'secondary'}>
{STATUS_LABEL[status] ?? status}
</Badge>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">
Empfänger
</dt>
<dd className="mt-1 text-sm font-semibold">
{String(invoice.recipient_name ?? '—')}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Rechnungsdatum
</dt>
<dd className="mt-1 text-sm font-semibold">
{invoice.issue_date
? new Date(
String(invoice.issue_date),
).toLocaleDateString('de-DE')
: '—'}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Fälligkeitsdatum
</dt>
<dd className="mt-1 text-sm font-semibold">
{invoice.due_date
? new Date(String(invoice.due_date)).toLocaleDateString(
'de-DE',
)
: '—'}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Gesamtbetrag
</dt>
<dd className="mt-1 text-sm font-semibold">
{invoice.total_amount != null
? formatCurrency(invoice.total_amount)
: '—'}
</dd>
</div>
</dl>
{/* Actions */}
<div className="mt-6 flex gap-3">
{status === 'draft' && (
<Button>
<Send className="mr-2 h-4 w-4" />
Senden
</Button>
)}
{(status === 'sent' || status === 'overdue') && (
<Button variant="outline">
<CheckCircle className="mr-2 h-4 w-4" />
Bezahlt markieren
</Button>
)}
</div>
</CardContent>
</Card>
{/* Line Items */}
<Card>
<CardHeader>
<CardTitle>Positionen ({items.length})</CardTitle>
</CardHeader>
<CardContent>
{items.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Keine Positionen vorhanden.
</p>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">
Beschreibung
</th>
<th className="p-3 text-right font-medium">Menge</th>
<th className="p-3 text-right font-medium">
Einzelpreis
</th>
<th className="p-3 text-right font-medium">Gesamt</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr
key={String(item.id)}
className="border-b hover:bg-muted/30"
>
<td className="p-3">
{String(item.description ?? '—')}
</td>
<td className="p-3 text-right">
{String(item.quantity ?? 0)}
</td>
<td className="p-3 text-right">
{item.unit_price != null
? formatCurrency(item.unit_price)
: '—'}
</td>
<td className="p-3 text-right font-medium">
{item.total_price != null
? formatCurrency(item.total_price)
: '—'}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t bg-muted/30">
<td colSpan={3} className="p-3 text-right font-medium">
Zwischensumme
</td>
<td className="p-3 text-right">
{formatCurrency(invoice.subtotal ?? 0)}
</td>
</tr>
<tr>
<td colSpan={3} className="p-3 text-right font-medium">
MwSt. ({Number(invoice.tax_rate ?? 19)}%)
</td>
<td className="p-3 text-right">
{formatCurrency(invoice.tax_amount ?? 0)}
</td>
</tr>
<tr className="border-t font-semibold">
<td colSpan={3} className="p-3 text-right">
Gesamtbetrag
</td>
<td className="p-3 text-right">
{formatCurrency(invoice.total_amount ?? 0)}
</td>
</tr>
</tfoot>
</table>
</div>
)}
</CardContent>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,212 @@
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Textarea } from '@kit/ui/textarea';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function NewInvoicePage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
return (
<CmsPageShell account={account} title="Neue Rechnung">
<div className="flex w-full flex-col gap-6">
{/* Back link */}
<div>
<Link
href={`/home/${account}/finance/invoices`}
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Zurück zu Rechnungen
</Link>
</div>
<Card className="max-w-3xl">
<CardHeader>
<CardTitle>Neue Rechnung</CardTitle>
<CardDescription>
Erstellen Sie eine neue Rechnung mit Positionen.
</CardDescription>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-6">
{/* Basic Info */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="invoiceNumber">Rechnungsnummer</Label>
<Input
id="invoiceNumber"
name="invoiceNumber"
placeholder="RE-2026-001"
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="recipientName">Empfänger</Label>
<Input
id="recipientName"
name="recipientName"
placeholder="Max Mustermann"
required
/>
</div>
</div>
{/* Recipient Address */}
<div className="flex flex-col gap-2">
<Label htmlFor="recipientAddress">Empfängeradresse</Label>
<Textarea
id="recipientAddress"
name="recipientAddress"
placeholder="Musterstraße 1&#10;12345 Musterstadt"
rows={3}
/>
</div>
{/* Dates + Tax */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Label htmlFor="issueDate">Rechnungsdatum</Label>
<Input
id="issueDate"
name="issueDate"
type="date"
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="dueDate">Fälligkeitsdatum</Label>
<Input
id="dueDate"
name="dueDate"
type="date"
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="taxRate">Steuersatz (%)</Label>
<Input
id="taxRate"
name="taxRate"
type="number"
step="0.01"
defaultValue="19"
required
/>
</div>
</div>
{/* Line Items */}
<div className="flex flex-col gap-3">
<Label>Positionen</Label>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">
Beschreibung
</th>
<th className="p-3 text-right font-medium">Menge</th>
<th className="p-3 text-right font-medium">
Einzelpreis ()
</th>
<th className="p-3 text-right font-medium">Gesamt</th>
</tr>
</thead>
<tbody>
{[0, 1, 2].map((idx) => (
<tr key={idx} className="border-b">
<td className="p-2">
<Input
name={`items[${idx}].description`}
placeholder="Leistungsbeschreibung"
className="border-0 shadow-none"
/>
</td>
<td className="w-24 p-2">
<Input
name={`items[${idx}].quantity`}
type="number"
min="0"
step="1"
defaultValue="1"
className="border-0 text-right shadow-none"
/>
</td>
<td className="w-32 p-2">
<Input
name={`items[${idx}].unitPrice`}
type="number"
min="0"
step="0.01"
defaultValue="0.00"
className="border-0 text-right shadow-none"
/>
</td>
<td className="w-32 p-3 text-right text-muted-foreground">
0,00
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Totals */}
<div className="ml-auto flex w-64 flex-col gap-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Zwischensumme</span>
<span>0,00 </span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">MwSt. (19%)</span>
<span>0,00 </span>
</div>
<div className="flex justify-between border-t pt-1 font-semibold">
<span>Gesamt</span>
<span>0,00 </span>
</div>
</div>
</div>
</form>
</CardContent>
<CardFooter className="flex justify-between">
<Link href={`/home/${account}/finance/invoices`}>
<Button variant="outline">Abbrechen</Button>
</Link>
<Button type="submit">Rechnung erstellen</Button>
</CardFooter>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,162 @@
import Link from 'next/link';
import { FileText, Plus } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createFinanceApi } from '@kit/finance/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
interface PageProps {
params: Promise<{ account: string }>;
}
const STATUS_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
draft: 'secondary',
sent: 'default',
paid: 'info',
overdue: 'destructive',
cancelled: 'destructive',
};
const STATUS_LABEL: Record<string, string> = {
draft: 'Entwurf',
sent: 'Versendet',
paid: 'Bezahlt',
overdue: 'Überfällig',
cancelled: 'Storniert',
};
const formatCurrency = (amount: unknown) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
Number(amount),
);
export default async function InvoicesPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createFinanceApi(client);
const invoices = await api.listInvoices(acct.id);
return (
<CmsPageShell account={account} title="Rechnungen">
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Rechnungen</h1>
<p className="text-muted-foreground">Rechnungen verwalten</p>
</div>
<Link href={`/home/${account}/finance/invoices/new`}>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neue Rechnung
</Button>
</Link>
</div>
{/* Table or Empty State */}
{invoices.length === 0 ? (
<EmptyState
icon={<FileText className="h-8 w-8" />}
title="Keine Rechnungen vorhanden"
description="Erstellen Sie Ihre erste Rechnung."
actionLabel="Neue Rechnung"
actionHref={`/home/${account}/finance/invoices/new`}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Rechnungen ({invoices.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Nr.</th>
<th className="p-3 text-left font-medium">Empfänger</th>
<th className="p-3 text-left font-medium">Datum</th>
<th className="p-3 text-left font-medium">Fällig</th>
<th className="p-3 text-right font-medium">Betrag</th>
<th className="p-3 text-left font-medium">Status</th>
</tr>
</thead>
<tbody>
{invoices.map((invoice: Record<string, unknown>) => {
const status = String(invoice.status);
return (
<tr
key={String(invoice.id)}
className="border-b hover:bg-muted/30"
>
<td className="p-3 font-mono text-xs">
<Link
href={`/home/${account}/finance/invoices/${String(invoice.id)}`}
className="hover:underline"
>
{String(invoice.invoice_number ?? '—')}
</Link>
</td>
<td className="p-3">
{String(invoice.recipient_name ?? '—')}
</td>
<td className="p-3">
{invoice.issue_date
? new Date(
String(invoice.issue_date),
).toLocaleDateString('de-DE')
: '—'}
</td>
<td className="p-3">
{invoice.due_date
? new Date(
String(invoice.due_date),
).toLocaleDateString('de-DE')
: '—'}
</td>
<td className="p-3 text-right">
{invoice.total_amount != null
? formatCurrency(invoice.total_amount)
: '—'}
</td>
<td className="p-3">
<Badge
variant={
STATUS_VARIANT[status] ?? 'secondary'
}
>
{STATUS_LABEL[status] ?? status}
</Badge>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,265 @@
import Link from 'next/link';
import { Landmark, FileText, Euro, ArrowRight } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createFinanceApi } from '@kit/finance/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card';
interface PageProps {
params: Promise<{ account: string }>;
}
const BATCH_STATUS_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
draft: 'secondary',
ready: 'default',
submitted: 'info',
completed: 'outline',
failed: 'destructive',
};
const BATCH_STATUS_LABEL: Record<string, string> = {
draft: 'Entwurf',
ready: 'Bereit',
submitted: 'Eingereicht',
completed: 'Abgeschlossen',
failed: 'Fehlgeschlagen',
};
const INVOICE_STATUS_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
draft: 'secondary',
sent: 'default',
paid: 'info',
overdue: 'destructive',
cancelled: 'destructive',
};
const INVOICE_STATUS_LABEL: Record<string, string> = {
draft: 'Entwurf',
sent: 'Versendet',
paid: 'Bezahlt',
overdue: 'Überfällig',
cancelled: 'Storniert',
};
export default async function FinancePage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createFinanceApi(client);
const [batches, invoices] = await Promise.all([
api.listBatches(acct.id),
api.listInvoices(acct.id),
]);
const openAmount = invoices
.filter(
(inv: Record<string, unknown>) =>
inv.status === 'sent' || inv.status === 'overdue',
)
.reduce(
(sum: number, inv: Record<string, unknown>) =>
sum + (Number(inv.total_amount) || 0),
0,
);
return (
<CmsPageShell account={account} title="Finanzen">
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold">Finanzen</h1>
<p className="text-muted-foreground">
SEPA-Einzüge und Rechnungen
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatsCard
title="SEPA-Einzüge"
value={batches.length}
icon={<Landmark className="h-5 w-5" />}
/>
<StatsCard
title="Rechnungen"
value={invoices.length}
icon={<FileText className="h-5 w-5" />}
/>
<StatsCard
title="Offener Betrag"
value={`${openAmount.toFixed(2)}`}
icon={<Euro className="h-5 w-5" />}
/>
</div>
{/* SEPA Batches */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Letzte SEPA-Einzüge</CardTitle>
<Link href={`/home/${account}/finance/sepa`}>
<Button variant="ghost" size="sm">
Alle anzeigen
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</CardHeader>
<CardContent>
{batches.length === 0 ? (
<EmptyState
icon={<Landmark className="h-8 w-8" />}
title="Keine SEPA-Einzüge"
description="Erstellen Sie Ihren ersten SEPA-Einzug."
actionLabel="Neuer SEPA-Einzug"
actionHref={`/home/${account}/finance/sepa/new`}
/>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-left font-medium">Typ</th>
<th className="p-3 text-right font-medium">Betrag</th>
<th className="p-3 text-left font-medium">Datum</th>
</tr>
</thead>
<tbody>
{batches.slice(0, 5).map((batch: Record<string, unknown>) => (
<tr
key={String(batch.id)}
className="border-b hover:bg-muted/30"
>
<td className="p-3">
<Badge
variant={
BATCH_STATUS_VARIANT[String(batch.status)] ?? 'secondary'
}
>
{BATCH_STATUS_LABEL[String(batch.status)] ?? String(batch.status)}
</Badge>
</td>
<td className="p-3">
{batch.batch_type === 'direct_debit'
? 'Lastschrift'
: 'Überweisung'}
</td>
<td className="p-3 text-right">
{batch.total_amount != null
? `${Number(batch.total_amount).toFixed(2)}`
: '—'}
</td>
<td className="p-3">
{batch.execution_date
? new Date(String(batch.execution_date)).toLocaleDateString('de-DE')
: batch.created_at
? new Date(String(batch.created_at)).toLocaleDateString('de-DE')
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Invoices */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Letzte Rechnungen</CardTitle>
<Link href={`/home/${account}/finance/invoices`}>
<Button variant="ghost" size="sm">
Alle anzeigen
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</CardHeader>
<CardContent>
{invoices.length === 0 ? (
<EmptyState
icon={<FileText className="h-8 w-8" />}
title="Keine Rechnungen"
description="Erstellen Sie Ihre erste Rechnung."
actionLabel="Neue Rechnung"
actionHref={`/home/${account}/finance/invoices/new`}
/>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Nr.</th>
<th className="p-3 text-left font-medium">Empfänger</th>
<th className="p-3 text-right font-medium">Betrag</th>
<th className="p-3 text-left font-medium">Status</th>
</tr>
</thead>
<tbody>
{invoices.slice(0, 5).map((invoice: Record<string, unknown>) => (
<tr
key={String(invoice.id)}
className="border-b hover:bg-muted/30"
>
<td className="p-3 font-mono text-xs">
<Link
href={`/home/${account}/finance/invoices/${String(invoice.id)}`}
className="hover:underline"
>
{String(invoice.invoice_number ?? '—')}
</Link>
</td>
<td className="p-3">
{String(invoice.recipient_name ?? '—')}
</td>
<td className="p-3 text-right">
{invoice.total_amount != null
? `${Number(invoice.total_amount).toFixed(2)}`
: '—'}
</td>
<td className="p-3">
<Badge
variant={
INVOICE_STATUS_VARIANT[String(invoice.status)] ??
'secondary'
}
>
{INVOICE_STATUS_LABEL[String(invoice.status)] ??
String(invoice.status)}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,166 @@
import Link from 'next/link';
import { Euro, CreditCard, TrendingUp, ArrowRight } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createFinanceApi } from '@kit/finance/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { StatsCard } from '~/components/stats-card';
interface PageProps {
params: Promise<{ account: string }>;
}
const formatCurrency = (amount: number) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
amount,
);
export default async function PaymentsPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createFinanceApi(client);
const [batches, invoices] = await Promise.all([
api.listBatches(acct.id),
api.listInvoices(acct.id),
]);
const paidInvoices = invoices.filter(
(inv: Record<string, unknown>) => inv.status === 'paid',
);
const openInvoices = invoices.filter(
(inv: Record<string, unknown>) =>
inv.status === 'sent' || inv.status === 'overdue',
);
const overdueInvoices = invoices.filter(
(inv: Record<string, unknown>) => inv.status === 'overdue',
);
const paidTotal = paidInvoices.reduce(
(sum: number, inv: Record<string, unknown>) =>
sum + (Number(inv.total_amount) || 0),
0,
);
const openTotal = openInvoices.reduce(
(sum: number, inv: Record<string, unknown>) =>
sum + (Number(inv.total_amount) || 0),
0,
);
const overdueTotal = overdueInvoices.reduce(
(sum: number, inv: Record<string, unknown>) =>
sum + (Number(inv.total_amount) || 0),
0,
);
const sepaTotal = batches.reduce(
(sum: number, b: Record<string, unknown>) =>
sum + (Number(b.total_amount) || 0),
0,
);
return (
<CmsPageShell account={account} title="Zahlungen">
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold">Zahlungsübersicht</h1>
<p className="text-muted-foreground">
Zusammenfassung aller Zahlungen und offenen Beträge
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Bezahlt"
value={formatCurrency(paidTotal)}
icon={<Euro className="h-5 w-5" />}
description={`${paidInvoices.length} Rechnungen`}
/>
<StatsCard
title="Offen"
value={formatCurrency(openTotal)}
icon={<CreditCard className="h-5 w-5" />}
description={`${openInvoices.length} Rechnungen`}
/>
<StatsCard
title="Überfällig"
value={formatCurrency(overdueTotal)}
icon={<TrendingUp className="h-5 w-5" />}
description={`${overdueInvoices.length} Rechnungen`}
/>
<StatsCard
title="SEPA-Einzüge"
value={formatCurrency(sepaTotal)}
icon={<Euro className="h-5 w-5" />}
description={`${batches.length} Einzüge`}
/>
</div>
{/* Quick Links */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Offene Rechnungen</CardTitle>
<Badge variant={openInvoices.length > 0 ? 'default' : 'secondary'}>
{openInvoices.length}
</Badge>
</CardHeader>
<CardContent>
<p className="mb-4 text-sm text-muted-foreground">
{openInvoices.length > 0
? `${openInvoices.length} Rechnungen mit einem Gesamtbetrag von ${formatCurrency(openTotal)} sind offen.`
: 'Keine offenen Rechnungen vorhanden.'}
</p>
<Link href={`/home/${account}/finance/invoices`}>
<Button variant="outline" size="sm">
Rechnungen anzeigen
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">SEPA-Einzüge</CardTitle>
<Badge variant={batches.length > 0 ? 'default' : 'secondary'}>
{batches.length}
</Badge>
</CardHeader>
<CardContent>
<p className="mb-4 text-sm text-muted-foreground">
{batches.length > 0
? `${batches.length} SEPA-Einzüge mit einem Gesamtvolumen von ${formatCurrency(sepaTotal)}.`
: 'Keine SEPA-Einzüge vorhanden.'}
</p>
<Link href={`/home/${account}/finance/sepa`}>
<Button variant="outline" size="sm">
Einzüge anzeigen
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</CardContent>
</Card>
</div>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,218 @@
import Link from 'next/link';
import { ArrowLeft, Download } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createFinanceApi } from '@kit/finance/api';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string; batchId: string }>;
}
const STATUS_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
draft: 'secondary',
ready: 'default',
submitted: 'info',
completed: 'outline',
failed: 'destructive',
};
const STATUS_LABEL: Record<string, string> = {
draft: 'Entwurf',
ready: 'Bereit',
submitted: 'Eingereicht',
completed: 'Abgeschlossen',
failed: 'Fehlgeschlagen',
};
const ITEM_STATUS_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
pending: 'secondary',
processed: 'default',
failed: 'destructive',
};
const ITEM_STATUS_LABEL: Record<string, string> = {
pending: 'Ausstehend',
processed: 'Verarbeitet',
failed: 'Fehlgeschlagen',
};
const formatCurrency = (amount: unknown) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
Number(amount),
);
export default async function SepaBatchDetailPage({ params }: PageProps) {
const { account, batchId } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createFinanceApi(client);
const [batch, items] = await Promise.all([
api.getBatch(batchId),
api.getBatchItems(batchId),
]);
if (!batch) return <div>Einzug nicht gefunden</div>;
const status = String(batch.status);
return (
<CmsPageShell account={account} title="SEPA-Einzug Details">
<div className="flex w-full flex-col gap-6">
{/* Back link */}
<div>
<Link
href={`/home/${account}/finance/sepa`}
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Zurück zu SEPA-Lastschriften
</Link>
</div>
{/* Summary Card */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>
{String(batch.description ?? 'SEPA-Einzug')}
</CardTitle>
<Badge variant={STATUS_VARIANT[status] ?? 'secondary'}>
{STATUS_LABEL[status] ?? status}
</Badge>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">
Typ
</dt>
<dd className="mt-1 text-sm font-semibold">
{batch.batch_type === 'direct_debit'
? 'Lastschrift'
: 'Überweisung'}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Betrag
</dt>
<dd className="mt-1 text-sm font-semibold">
{batch.total_amount != null
? formatCurrency(batch.total_amount)
: '—'}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Anzahl
</dt>
<dd className="mt-1 text-sm font-semibold">
{String(batch.item_count ?? items.length)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Ausführungsdatum
</dt>
<dd className="mt-1 text-sm font-semibold">
{batch.execution_date
? new Date(
String(batch.execution_date),
).toLocaleDateString('de-DE')
: '—'}
</dd>
</div>
</dl>
<div className="mt-6">
<Button disabled variant="outline">
<Download className="mr-2 h-4 w-4" />
XML herunterladen
</Button>
</div>
</CardContent>
</Card>
{/* Items Table */}
<Card>
<CardHeader>
<CardTitle>Positionen ({items.length})</CardTitle>
</CardHeader>
<CardContent>
{items.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Keine Positionen vorhanden.
</p>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">IBAN</th>
<th className="p-3 text-right font-medium">Betrag</th>
<th className="p-3 text-left font-medium">Status</th>
</tr>
</thead>
<tbody>
{items.map((item: Record<string, unknown>) => {
const itemStatus = String(item.status ?? 'pending');
return (
<tr
key={String(item.id)}
className="border-b hover:bg-muted/30"
>
<td className="p-3 font-medium">
{String(item.debtor_name ?? '—')}
</td>
<td className="p-3 font-mono text-xs">
{String(item.debtor_iban ?? '—')}
</td>
<td className="p-3 text-right">
{item.amount != null
? formatCurrency(item.amount)
: '—'}
</td>
<td className="p-3">
<Badge
variant={
ITEM_STATUS_VARIANT[itemStatus] ?? 'secondary'
}
>
{ITEM_STATUS_LABEL[itemStatus] ?? itemStatus}
</Badge>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,117 @@
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function NewSepaPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
return (
<CmsPageShell account={account} title="Neuer SEPA-Einzug">
<div className="flex w-full flex-col gap-6">
{/* Back link */}
<div>
<Link
href={`/home/${account}/finance/sepa`}
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Zurück zu SEPA-Lastschriften
</Link>
</div>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>Neuer SEPA-Einzug</CardTitle>
<CardDescription>
Erstellen Sie einen neuen Lastschrifteinzug oder eine Überweisung.
</CardDescription>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-5">
{/* Typ */}
<div className="flex flex-col gap-2">
<Label htmlFor="batchType">Typ</Label>
<select
id="batchType"
name="batchType"
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
defaultValue="direct_debit"
>
<option value="direct_debit">Lastschrift</option>
<option value="credit_transfer">Überweisung</option>
</select>
</div>
{/* Beschreibung */}
<div className="flex flex-col gap-2">
<Label htmlFor="description">Beschreibung</Label>
<Input
id="description"
name="description"
placeholder="z.B. Mitgliedsbeiträge Q1 2026"
required
/>
</div>
{/* Ausführungsdatum */}
<div className="flex flex-col gap-2">
<Label htmlFor="executionDate">Ausführungsdatum</Label>
<Input
id="executionDate"
name="executionDate"
type="date"
required
/>
</div>
{/* PAIN Format Info */}
<div className="rounded-md bg-muted/50 p-4 text-sm text-muted-foreground">
<p>
<strong>Hinweis:</strong> Nach dem Erstellen können Sie
einzelne Positionen hinzufügen und anschließend die
SEPA-XML-Datei generieren.
</p>
</div>
</form>
</CardContent>
<CardFooter className="flex justify-between">
<Link href={`/home/${account}/finance/sepa`}>
<Button variant="outline">Abbrechen</Button>
</Link>
<Button type="submit">Einzug erstellen</Button>
</CardFooter>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,164 @@
import Link from 'next/link';
import { Landmark, Plus } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createFinanceApi } from '@kit/finance/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
interface PageProps {
params: Promise<{ account: string }>;
}
const STATUS_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
draft: 'secondary',
ready: 'default',
submitted: 'info',
completed: 'outline',
failed: 'destructive',
};
const STATUS_LABEL: Record<string, string> = {
draft: 'Entwurf',
ready: 'Bereit',
submitted: 'Eingereicht',
completed: 'Abgeschlossen',
failed: 'Fehlgeschlagen',
};
const formatCurrency = (amount: unknown) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
Number(amount),
);
export default async function SepaPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createFinanceApi(client);
const batches = await api.listBatches(acct.id);
return (
<CmsPageShell account={account} title="SEPA-Lastschriften">
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">SEPA-Lastschriften</h1>
<p className="text-muted-foreground">
Lastschrifteinzüge verwalten
</p>
</div>
<Link href={`/home/${account}/finance/sepa/new`}>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neuer Einzug
</Button>
</Link>
</div>
{/* Table or Empty State */}
{batches.length === 0 ? (
<EmptyState
icon={<Landmark className="h-8 w-8" />}
title="Keine SEPA-Einzüge"
description="Erstellen Sie Ihren ersten SEPA-Einzug."
actionLabel="Neuer Einzug"
actionHref={`/home/${account}/finance/sepa/new`}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Einzüge ({batches.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-left font-medium">Typ</th>
<th className="p-3 text-left font-medium">
Beschreibung
</th>
<th className="p-3 text-right font-medium">Betrag</th>
<th className="p-3 text-right font-medium">Anzahl</th>
<th className="p-3 text-left font-medium">
Ausführungsdatum
</th>
</tr>
</thead>
<tbody>
{batches.map((batch: Record<string, unknown>) => (
<tr
key={String(batch.id)}
className="border-b hover:bg-muted/30"
>
<td className="p-3">
<Badge
variant={
STATUS_VARIANT[String(batch.status)] ?? 'secondary'
}
>
{STATUS_LABEL[String(batch.status)] ??
String(batch.status)}
</Badge>
</td>
<td className="p-3">
{batch.batch_type === 'direct_debit'
? 'Lastschrift'
: 'Überweisung'}
</td>
<td className="p-3">
<Link
href={`/home/${account}/finance/sepa/${String(batch.id)}`}
className="hover:underline"
>
{String(batch.description ?? '—')}
</Link>
</td>
<td className="p-3 text-right">
{batch.total_amount != null
? formatCurrency(batch.total_amount)
: '—'}
</td>
<td className="p-3 text-right">
{String(batch.item_count ?? 0)}
</td>
<td className="p-3">
{batch.execution_date
? new Date(
String(batch.execution_date),
).toLocaleDateString('de-DE')
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,173 @@
import Link from 'next/link';
import { User, Mail, Phone, MapPin, CreditCard, Pencil, Ban } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createMemberManagementApi } from '@kit/member-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string; memberId: string }>;
}
const STATUS_LABEL: Record<string, string> = {
active: 'Aktiv',
inactive: 'Inaktiv',
pending: 'Ausstehend',
cancelled: 'Gekündigt',
};
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
active: 'default',
inactive: 'secondary',
pending: 'outline',
cancelled: 'destructive',
};
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-medium">{value || '—'}</span>
</div>
);
}
export default async function MemberDetailPage({ params }: PageProps) {
const { account, memberId } = await params;
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const member = await api.getMember(memberId);
if (!member) return <div>Mitglied nicht gefunden</div>;
const m = member as Record<string, unknown>;
return (
<CmsPageShell account={account} title={`${String(m.first_name)} ${String(m.last_name)}`}>
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">
{String(m.first_name)} {String(m.last_name)}
</h1>
<div className="mt-1 flex items-center gap-2">
<Badge variant={STATUS_VARIANT[String(m.status)] ?? 'secondary'}>
{STATUS_LABEL[String(m.status)] ?? String(m.status)}
</Badge>
{m.member_number ? (
<span className="text-sm text-muted-foreground">
Nr. {String(m.member_number)}
</span>
) : null}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline">
<Pencil className="mr-2 h-4 w-4" />
Bearbeiten
</Button>
<Button variant="destructive">
<Ban className="mr-2 h-4 w-4" />
Kündigen
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Persönliche Daten */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-4 w-4" />
Persönliche Daten
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4">
<DetailRow label="Vorname" value={String(m.first_name ?? '')} />
<DetailRow label="Nachname" value={String(m.last_name ?? '')} />
<DetailRow
label="Geburtsdatum"
value={m.date_of_birth ? new Date(String(m.date_of_birth)).toLocaleDateString('de-DE') : ''}
/>
<DetailRow label="Geschlecht" value={String(m.gender ?? '')} />
</CardContent>
</Card>
{/* Kontakt */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-4 w-4" />
Kontakt
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4">
<DetailRow label="E-Mail" value={String(m.email ?? '')} />
<DetailRow label="Telefon" value={String(m.phone ?? '')} />
<DetailRow label="Mobil" value={String(m.mobile ?? '')} />
</CardContent>
</Card>
{/* Adresse */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
Adresse
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4">
<DetailRow label="Straße" value={`${String(m.street ?? '')} ${String(m.house_number ?? '')}`} />
<DetailRow label="PLZ / Ort" value={`${String(m.postal_code ?? '')} ${String(m.city ?? '')}`} />
</CardContent>
</Card>
{/* Mitgliedschaft */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-4 w-4" />
Mitgliedschaft
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4">
<DetailRow label="Mitgliedsnr." value={String(m.member_number ?? '')} />
<DetailRow label="Status" value={STATUS_LABEL[String(m.status)] ?? String(m.status ?? '')} />
<DetailRow
label="Eintrittsdatum"
value={m.entry_date ? new Date(String(m.entry_date)).toLocaleDateString('de-DE') : ''}
/>
<DetailRow
label="Austrittsdatum"
value={m.exit_date ? new Date(String(m.exit_date)).toLocaleDateString('de-DE') : ''}
/>
</CardContent>
</Card>
{/* SEPA */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CreditCard className="h-4 w-4" />
SEPA-Bankverbindung
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<DetailRow label="IBAN" value={String(m.iban ?? '')} />
<DetailRow label="BIC" value={String(m.bic ?? '')} />
<DetailRow label="Kontoinhaber" value={String(m.account_holder ?? '')} />
</CardContent>
</Card>
</div>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,117 @@
import { UserCheck, UserX, FileText } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createMemberManagementApi } from '@kit/member-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
interface PageProps {
params: Promise<{ account: string }>;
}
const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'destructive'> = {
pending: 'secondary',
approved: 'default',
rejected: 'destructive',
};
const STATUS_LABEL: Record<string, string> = {
pending: 'Ausstehend',
approved: 'Genehmigt',
rejected: 'Abgelehnt',
};
export default async function ApplicationsPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createMemberManagementApi(client);
const applications = await api.listApplications(acct.id);
return (
<CmsPageShell account={account} title="Anträge">
<div className="flex w-full flex-col gap-6">
<div>
<h1 className="text-2xl font-bold">Mitgliedsanträge</h1>
<p className="text-muted-foreground">Eingehende Anträge prüfen und bearbeiten</p>
</div>
{applications.length === 0 ? (
<EmptyState
icon={<FileText className="h-8 w-8" />}
title="Keine Anträge"
description="Es liegen derzeit keine Mitgliedsanträge vor."
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Anträge ({applications.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">E-Mail</th>
<th className="p-3 text-left font-medium">Datum</th>
<th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-right font-medium">Aktionen</th>
</tr>
</thead>
<tbody>
{applications.map((app: Record<string, unknown>) => (
<tr key={String(app.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-medium">
{String(app.last_name ?? '')}, {String(app.first_name ?? '')}
</td>
<td className="p-3">{String(app.email ?? '—')}</td>
<td className="p-3">
{app.created_at
? new Date(String(app.created_at)).toLocaleDateString('de-DE')
: '—'}
</td>
<td className="p-3">
<Badge variant={STATUS_VARIANT[String(app.status)] ?? 'secondary'}>
{STATUS_LABEL[String(app.status)] ?? String(app.status)}
</Badge>
</td>
<td className="p-3 text-right">
{String(app.status) === 'pending' && (
<div className="flex justify-end gap-2">
<Button size="sm" variant="default">
<UserCheck className="mr-1 h-3 w-3" />
Genehmigen
</Button>
<Button size="sm" variant="destructive">
<UserX className="mr-1 h-3 w-3" />
Ablehnen
</Button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,95 @@
import { Euro, Plus } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createMemberManagementApi } from '@kit/member-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function DuesPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createMemberManagementApi(client);
const categories = await api.listDuesCategories(acct.id);
return (
<CmsPageShell account={account} title="Beitragskategorien">
<div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Beitragskategorien</h1>
<p className="text-muted-foreground">Beiträge und Gebühren verwalten</p>
</div>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neue Kategorie
</Button>
</div>
{categories.length === 0 ? (
<EmptyState
icon={<Euro className="h-8 w-8" />}
title="Keine Beitragskategorien"
description="Legen Sie Ihre erste Beitragskategorie an."
actionLabel="Neue Kategorie"
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Kategorien ({categories.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">Beschreibung</th>
<th className="p-3 text-right font-medium">Betrag ()</th>
<th className="p-3 text-left font-medium">Intervall</th>
<th className="p-3 text-center font-medium">Standard</th>
</tr>
</thead>
<tbody>
{categories.map((cat: Record<string, unknown>) => (
<tr key={String(cat.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-medium">{String(cat.name)}</td>
<td className="p-3 text-muted-foreground">
{String(cat.description ?? '—')}
</td>
<td className="p-3 text-right">
{cat.amount != null ? `${Number(cat.amount).toFixed(2)}` : '—'}
</td>
<td className="p-3">{String(cat.interval ?? '—')}</td>
<td className="p-3 text-center">
{cat.is_default ? '✓' : '✗'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,183 @@
import { UserPlus } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function NewMemberPage({ params }: PageProps) {
const { account } = await params;
return (
<CmsPageShell account={account} title="Neues Mitglied">
<div className="flex w-full flex-col gap-6">
<div>
<h1 className="text-2xl font-bold">Neues Mitglied</h1>
<p className="text-muted-foreground">Mitglied manuell anlegen</p>
</div>
<form className="flex flex-col gap-6">
{/* Persönliche Daten */}
<Card>
<CardHeader>
<CardTitle>Persönliche Daten</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="firstName">Vorname</Label>
<Input id="firstName" name="firstName" required />
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Nachname</Label>
<Input id="lastName" name="lastName" required />
</div>
<div className="space-y-2">
<Label htmlFor="dateOfBirth">Geburtsdatum</Label>
<Input id="dateOfBirth" name="dateOfBirth" type="date" />
</div>
<div className="space-y-2">
<Label htmlFor="gender">Geschlecht</Label>
<select
id="gender"
name="gender"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value=""> Bitte wählen </option>
<option value="male">Männlich</option>
<option value="female">Weiblich</option>
<option value="diverse">Divers</option>
</select>
</div>
</CardContent>
</Card>
{/* Kontakt */}
<Card>
<CardHeader>
<CardTitle>Kontakt</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="email">E-Mail</Label>
<Input id="email" name="email" type="email" />
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Input id="phone" name="phone" type="tel" />
</div>
<div className="space-y-2">
<Label htmlFor="mobile">Mobil</Label>
<Input id="mobile" name="mobile" type="tel" />
</div>
</CardContent>
</Card>
{/* Adresse */}
<Card>
<CardHeader>
<CardTitle>Adresse</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-1">
<Label htmlFor="street">Straße</Label>
<Input id="street" name="street" />
</div>
<div className="space-y-2">
<Label htmlFor="houseNumber">Hausnummer</Label>
<Input id="houseNumber" name="houseNumber" />
</div>
<div className="space-y-2">
<Label htmlFor="postalCode">PLZ</Label>
<Input id="postalCode" name="postalCode" />
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Input id="city" name="city" />
</div>
</CardContent>
</Card>
{/* Mitgliedschaft */}
<Card>
<CardHeader>
<CardTitle>Mitgliedschaft</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="memberNumber">Mitgliedsnr.</Label>
<Input id="memberNumber" name="memberNumber" />
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<select
id="status"
name="status"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
<option value="pending">Ausstehend</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="entryDate">Eintrittsdatum</Label>
<Input id="entryDate" name="entryDate" type="date" />
</div>
</CardContent>
</Card>
{/* SEPA */}
<Card>
<CardHeader>
<CardTitle>SEPA-Bankverbindung</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="iban">IBAN</Label>
<Input id="iban" name="iban" placeholder="DE89 3704 0044 0532 0130 00" />
</div>
<div className="space-y-2">
<Label htmlFor="bic">BIC</Label>
<Input id="bic" name="bic" />
</div>
<div className="space-y-2">
<Label htmlFor="accountHolder">Kontoinhaber</Label>
<Input id="accountHolder" name="accountHolder" />
</div>
</CardContent>
</Card>
{/* Notizen */}
<Card>
<CardHeader>
<CardTitle>Notizen</CardTitle>
</CardHeader>
<CardContent>
<textarea
name="notes"
rows={4}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="Zusätzliche Anmerkungen…"
/>
</CardContent>
</Card>
{/* Submit */}
<div className="flex justify-end">
<Button type="submit" size="lg">
<UserPlus className="mr-2 h-4 w-4" />
Mitglied erstellen
</Button>
</div>
</form>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,66 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMemberManagementApi } from '@kit/member-management/api';
interface MembersPageProps {
params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function MembersPage({ params, searchParams }: MembersPageProps) {
const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const { data: accountData } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!accountData) return <div>Konto nicht gefunden</div>;
const page = Number(search.page) || 1;
const result = await api.listMembers(accountData.id, {
search: search.q as string,
status: search.status as string,
page,
});
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Mitglieder</h1>
<p className="text-muted-foreground">{result.total} Mitglieder</p>
</div>
</div>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left">Nr.</th>
<th className="p-3 text-left">Name</th>
<th className="p-3 text-left">E-Mail</th>
<th className="p-3 text-left">Ort</th>
<th className="p-3 text-left">Status</th>
</tr>
</thead>
<tbody>
{result.data.map((member: Record<string, unknown>) => (
<tr key={String(member.id)} className="border-b hover:bg-muted/30">
<td className="p-3">{String(member.member_number ?? '—')}</td>
<td className="p-3 font-medium">{String(member.last_name)}, {String(member.first_name)}</td>
<td className="p-3">{String(member.email ?? '—')}</td>
<td className="p-3">{String(member.postal_code ?? '')} {String(member.city ?? '')}</td>
<td className="p-3">{String(member.status)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { Users, UserCheck, UserMinus, Clock, BarChart3, TrendingUp } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createMemberManagementApi } from '@kit/member-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { StatsCard } from '~/components/stats-card';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function MemberStatisticsPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createMemberManagementApi(client);
const stats = await api.getMemberStatistics(acct.id);
return (
<CmsPageShell account={account} title="Mitglieder-Statistiken">
<div className="flex w-full flex-col gap-6">
<div>
<h1 className="text-2xl font-bold">Mitglieder-Statistiken</h1>
<p className="text-muted-foreground">Übersicht über Ihre Mitglieder</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Gesamt"
value={stats.total ?? 0}
icon={<Users className="h-5 w-5" />}
/>
<StatsCard
title="Aktiv"
value={stats.active ?? 0}
icon={<UserCheck className="h-5 w-5" />}
/>
<StatsCard
title="Inaktiv"
value={stats.inactive ?? 0}
icon={<UserMinus className="h-5 w-5" />}
/>
<StatsCard
title="Ausstehend"
value={stats.pending ?? 0}
icon={<Clock className="h-5 w-5" />}
/>
</div>
{/* Chart Placeholders */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Mitgliederentwicklung
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex h-64 items-center justify-center rounded-md border border-dashed text-muted-foreground">
Diagramm wird hier angezeigt
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Eintritte / Austritte pro Monat
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex h-64 items-center justify-center rounded-md border border-dashed text-muted-foreground">
Diagramm wird hier angezeigt
</div>
</CardContent>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,92 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createModuleBuilderApi } from '@kit/module-builder/api';
import { ModuleForm } from '@kit/module-builder/components';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Pencil, Trash2, Lock, Unlock } from 'lucide-react';
import Link from 'next/link';
import { CmsPageShell } from '~/components/cms-page-shell';
interface RecordDetailPageProps {
params: Promise<{ account: string; moduleId: string; recordId: string }>;
}
export default async function RecordDetailPage({ params }: RecordDetailPageProps) {
const { account, moduleId, recordId } = await params;
const client = getSupabaseServerClient();
const api = createModuleBuilderApi(client);
const [moduleWithFields, record] = await Promise.all([
api.modules.getModuleWithFields(moduleId),
api.records.getRecord(recordId),
]);
if (!moduleWithFields || !record) return <div>Nicht gefunden</div>;
const fields = (moduleWithFields as unknown as {
fields: Array<{
name: string; display_name: string; field_type: string;
is_required: boolean; placeholder: string | null;
help_text: string | null; is_readonly: boolean;
select_options: Array<{ label: string; value: string }> | null;
section: string; sort_order: number; show_in_form: boolean; width: string;
}>;
}).fields;
const data = (record.data ?? {}) as Record<string, unknown>;
const isLocked = record.status === 'locked';
return (
<CmsPageShell account={account} title={`${String(moduleWithFields.display_name)} — Datensatz`}>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant={isLocked ? 'destructive' : record.status === 'active' ? 'default' : 'secondary'}>
{String(record.status)}
</Badge>
<span className="text-sm text-muted-foreground">
Erstellt: {new Date(record.created_at).toLocaleDateString('de-DE')}
</span>
</div>
<div className="flex gap-2">
{isLocked ? (
<Button variant="outline" size="sm">
<Unlock className="mr-2 h-4 w-4" />
Entsperren
</Button>
) : (
<Button variant="outline" size="sm">
<Lock className="mr-2 h-4 w-4" />
Sperren
</Button>
)}
<Button variant="destructive" size="sm">
<Trash2 className="mr-2 h-4 w-4" />
Löschen
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Pencil className="h-4 w-4" />
Datensatz bearbeiten
</CardTitle>
</CardHeader>
<CardContent>
<ModuleForm
fields={fields as Parameters<typeof ModuleForm>[0]['fields']}
initialData={data}
onSubmit={async () => {}}
isLoading={false}
/>
</CardContent>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,83 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createModuleBuilderApi } from '@kit/module-builder/api';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Button } from '@kit/ui/button';
import { Upload, ArrowRight, CheckCircle } from 'lucide-react';
import { CmsPageShell } from '~/components/cms-page-shell';
interface ImportPageProps {
params: Promise<{ account: string; moduleId: string }>;
}
export default async function ImportPage({ params }: ImportPageProps) {
const { account, moduleId } = await params;
const client = getSupabaseServerClient();
const api = createModuleBuilderApi(client);
const moduleWithFields = await api.modules.getModuleWithFields(moduleId);
if (!moduleWithFields) return <div>Modul nicht gefunden</div>;
const fields = (moduleWithFields as unknown as { fields: Array<{ name: string; display_name: string }> }).fields ?? [];
return (
<CmsPageShell account={account} title={`${String(moduleWithFields.display_name)} — Import`}>
<div className="space-y-6">
{/* Step indicator */}
<div className="flex items-center justify-center gap-2">
{['Datei hochladen', 'Spalten zuordnen', 'Vorschau', 'Importieren'].map((step, i) => (
<div key={step} className="flex items-center gap-2">
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold ${
i === 0 ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
}`}>
{i + 1}
</div>
<span className={`text-sm ${i === 0 ? 'font-semibold' : 'text-muted-foreground'}`}>{step}</span>
{i < 3 && <ArrowRight className="h-4 w-4 text-muted-foreground" />}
</div>
))}
</div>
{/* Upload Step */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-4 w-4" />
Datei hochladen
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-12 text-center">
<Upload className="mb-4 h-10 w-10 text-muted-foreground" />
<p className="text-lg font-semibold">CSV oder Excel-Datei hierher ziehen</p>
<p className="mt-1 text-sm text-muted-foreground">oder klicken zum Auswählen</p>
<input
type="file"
accept=".csv,.xlsx,.xls"
className="mt-4 block w-full max-w-xs text-sm text-muted-foreground file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground hover:file:bg-primary/90"
/>
</div>
<div className="mt-6">
<h4 className="text-sm font-semibold mb-2">Verfügbare Zielfelder:</h4>
<div className="flex flex-wrap gap-1">
{fields.map((field) => (
<span key={field.name} className="rounded-md bg-muted px-2 py-1 text-xs">
{field.display_name}
</span>
))}
</div>
</div>
<div className="mt-6 flex justify-end">
<Button disabled>
Weiter <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,40 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createModuleBuilderApi } from '@kit/module-builder/api';
import { ModuleForm } from '@kit/module-builder/components';
import { CmsPageShell } from '~/components/cms-page-shell';
interface NewRecordPageProps {
params: Promise<{ account: string; moduleId: string }>;
}
export default async function NewRecordPage({ params }: NewRecordPageProps) {
const { account, moduleId } = await params;
const client = getSupabaseServerClient();
const api = createModuleBuilderApi(client);
const moduleWithFields = await api.modules.getModuleWithFields(moduleId);
if (!moduleWithFields) return <div>Modul nicht gefunden</div>;
const fields = (moduleWithFields as unknown as {
fields: Array<{
name: string; display_name: string; field_type: string;
is_required: boolean; placeholder: string | null;
help_text: string | null; is_readonly: boolean;
select_options: Array<{ label: string; value: string }> | null;
section: string; sort_order: number; show_in_form: boolean; width: string;
}>;
}).fields;
return (
<CmsPageShell account={account} title={`${String(moduleWithFields.display_name)} — Neuer Datensatz`}>
<div className="mx-auto max-w-3xl">
<ModuleForm
fields={fields as Parameters<typeof ModuleForm>[0]['fields']}
onSubmit={async () => {}}
isLoading={false}
/>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,58 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createModuleBuilderApi } from '@kit/module-builder/api';
interface ModuleDetailPageProps {
params: Promise<{ account: string; moduleId: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function ModuleDetailPage({ params, searchParams }: ModuleDetailPageProps) {
const { account, moduleId } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();
const api = createModuleBuilderApi(client);
const moduleWithFields = await api.modules.getModuleWithFields(moduleId);
if (!moduleWithFields) {
return <div>Modul nicht gefunden</div>;
}
const page = Number(search.page) || 1;
const pageSize = Number(search.pageSize) || moduleWithFields.default_page_size || 25;
const result = await api.query.query({
moduleId,
page,
pageSize,
sortField: (search.sort as string) ?? moduleWithFields.default_sort_field ?? undefined,
sortDirection: (search.dir as 'asc' | 'desc') ?? (moduleWithFields.default_sort_direction as 'asc' | 'desc') ?? 'asc',
search: (search.q as string) ?? undefined,
filters: [],
});
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{moduleWithFields.display_name}</h1>
{moduleWithFields.description && (
<p className="text-muted-foreground">{moduleWithFields.description}</p>
)}
</div>
</div>
<div className="text-sm text-muted-foreground">
{result.pagination.total} Datensätze Seite {result.pagination.page} von {result.pagination.totalPages}
</div>
{/* Phase 3 will replace this with module-table component */}
<div className="rounded-lg border">
<pre className="p-4 text-xs overflow-auto max-h-96">
{JSON.stringify(result.data, null, 2)}
</pre>
</div>
</div>
);
}

View File

@@ -0,0 +1,152 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createModuleBuilderApi } from '@kit/module-builder/api';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Settings2, List, Shield } from 'lucide-react';
import { CmsPageShell } from '~/components/cms-page-shell';
interface ModuleSettingsPageProps {
params: Promise<{ account: string; moduleId: string }>;
}
export default async function ModuleSettingsPage({ params }: ModuleSettingsPageProps) {
const { account, moduleId } = await params;
const client = getSupabaseServerClient();
const api = createModuleBuilderApi(client);
const moduleWithFields = await api.modules.getModuleWithFields(moduleId);
if (!moduleWithFields) return <div>Modul nicht gefunden</div>;
const mod = moduleWithFields;
const fields = (mod as unknown as { fields: Array<Record<string, unknown>> }).fields ?? [];
return (
<CmsPageShell account={account} title={`${String(mod.display_name)} — Einstellungen`}>
<div className="space-y-6">
{/* General Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings2 className="h-4 w-4" />
Allgemein
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Anzeigename</Label>
<Input defaultValue={String(mod.display_name)} />
</div>
<div className="space-y-2">
<Label>Systemname</Label>
<Input defaultValue={String(mod.name)} readOnly className="bg-muted" />
</div>
<div className="col-span-full space-y-2">
<Label>Beschreibung</Label>
<Input defaultValue={String(mod.description ?? '')} />
</div>
<div className="space-y-2">
<Label>Symbol</Label>
<Input defaultValue={String(mod.icon ?? 'table')} />
</div>
<div className="space-y-2">
<Label>Seitengröße</Label>
<Input type="number" defaultValue={String(mod.default_page_size ?? 25)} />
</div>
</div>
<div className="flex flex-wrap gap-2">
{[
{ key: 'enable_search', label: 'Suche' },
{ key: 'enable_filter', label: 'Filter' },
{ key: 'enable_export', label: 'Export' },
{ key: 'enable_import', label: 'Import' },
{ key: 'enable_print', label: 'Drucken' },
{ key: 'enable_copy', label: 'Kopieren' },
{ key: 'enable_history', label: 'Verlauf' },
{ key: 'enable_soft_delete', label: 'Papierkorb' },
{ key: 'enable_lock', label: 'Sperren' },
].map(({ key, label }) => (
<Badge
key={key}
variant={(mod as Record<string, unknown>)[key] ? 'default' : 'secondary'}
>
{(mod as Record<string, unknown>)[key] ? '✓' : '✗'} {label}
</Badge>
))}
</div>
<Button>Einstellungen speichern</Button>
</CardContent>
</Card>
{/* Field Definitions */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<List className="h-4 w-4" />
Felder ({fields.length})
</CardTitle>
<Button size="sm">+ Feld hinzufügen</Button>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left">Name</th>
<th className="p-3 text-left">Anzeigename</th>
<th className="p-3 text-left">Typ</th>
<th className="p-3 text-left">Pflicht</th>
<th className="p-3 text-left">Tabelle</th>
<th className="p-3 text-left">Formular</th>
</tr>
</thead>
<tbody>
{fields.length === 0 ? (
<tr>
<td colSpan={6} className="p-8 text-center text-muted-foreground">
Noch keine Felder definiert
</td>
</tr>
) : (
fields.map((field) => (
<tr key={String(field.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-mono text-xs">{String(field.name)}</td>
<td className="p-3">{String(field.display_name)}</td>
<td className="p-3">
<Badge variant="secondary">{String(field.field_type)}</Badge>
</td>
<td className="p-3">{field.is_required ? '✓' : '—'}</td>
<td className="p-3">{field.show_in_table ? '✓' : '—'}</td>
<td className="p-3">{field.show_in_form ? '✓' : '—'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Permissions */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Berechtigungen
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Modulspezifische Berechtigungen pro Rolle können hier konfiguriert werden.
</p>
</CardContent>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,66 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createModuleBuilderApi } from '@kit/module-builder/api';
interface ModulesPageProps {
params: Promise<{ account: string }>;
}
export default async function ModulesPage({ params }: ModulesPageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const api = createModuleBuilderApi(client);
// Get the account ID from slug
const { data: accountData } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!accountData) {
return <div>Account not found</div>;
}
const modules = await api.modules.listModules(accountData.id);
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Module</h1>
<p className="text-muted-foreground">
Verwalten Sie Ihre Datenmodule
</p>
</div>
</div>
{modules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-muted-foreground">
Noch keine Module vorhanden. Erstellen Sie Ihr erstes Modul.
</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{modules.map((module: Record<string, unknown>) => (
<div
key={module.id as string}
className="rounded-lg border p-4 hover:bg-accent/50 transition-colors"
>
<h3 className="font-semibold">{String(module.display_name)}</h3>
{module.description ? (
<p className="text-sm text-muted-foreground mt-1">
{String(module.description)}
</p>
) : null}
<div className="mt-2 text-xs text-muted-foreground">
Status: {String(module.status)}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,196 @@
import Link from 'next/link';
import { ArrowLeft, Send, Users } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createNewsletterApi } from '@kit/newsletter/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { StatsCard } from '~/components/stats-card';
interface PageProps {
params: Promise<{ account: string; campaignId: string }>;
}
const STATUS_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
draft: 'secondary',
scheduled: 'default',
sending: 'info',
sent: 'outline',
failed: 'destructive',
};
const STATUS_LABEL: Record<string, string> = {
draft: 'Entwurf',
scheduled: 'Geplant',
sending: 'Wird gesendet',
sent: 'Gesendet',
failed: 'Fehlgeschlagen',
};
const RECIPIENT_STATUS_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
pending: 'secondary',
sent: 'default',
failed: 'destructive',
bounced: 'destructive',
};
const RECIPIENT_STATUS_LABEL: Record<string, string> = {
pending: 'Ausstehend',
sent: 'Gesendet',
failed: 'Fehlgeschlagen',
bounced: 'Zurückgewiesen',
};
export default async function NewsletterDetailPage({ params }: PageProps) {
const { account, campaignId } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createNewsletterApi(client);
const [newsletter, recipients] = await Promise.all([
api.getNewsletter(campaignId),
api.getRecipients(campaignId),
]);
if (!newsletter) return <div>Newsletter nicht gefunden</div>;
const status = String(newsletter.status);
const sentCount = recipients.filter(
(r: Record<string, unknown>) => r.status === 'sent',
).length;
const failedCount = recipients.filter(
(r: Record<string, unknown>) =>
r.status === 'failed' || r.status === 'bounced',
).length;
return (
<CmsPageShell account={account} title="Newsletter Details">
<div className="flex w-full flex-col gap-6">
{/* Back link */}
<div>
<Link
href={`/home/${account}/newsletter`}
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Zurück zu Newsletter
</Link>
</div>
{/* Summary Card */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>
{String(newsletter.subject ?? '(Kein Betreff)')}
</CardTitle>
<Badge variant={STATUS_VARIANT[status] ?? 'secondary'}>
{STATUS_LABEL[status] ?? status}
</Badge>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatsCard
title="Empfänger"
value={recipients.length}
icon={<Users className="h-5 w-5" />}
/>
<StatsCard
title="Gesendet"
value={sentCount}
icon={<Send className="h-5 w-5" />}
/>
<StatsCard
title="Fehlgeschlagen"
value={failedCount}
icon={<Send className="h-5 w-5" />}
/>
</div>
{/* Actions */}
{status === 'draft' && (
<div className="mt-6">
<Button>
<Send className="mr-2 h-4 w-4" />
Newsletter versenden
</Button>
</div>
)}
</CardContent>
</Card>
{/* Recipients Table */}
<Card>
<CardHeader>
<CardTitle>Empfänger ({recipients.length})</CardTitle>
</CardHeader>
<CardContent>
{recipients.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Keine Empfänger hinzugefügt. Fügen Sie Empfänger aus Ihrer
Mitgliederliste hinzu.
</p>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">E-Mail</th>
<th className="p-3 text-left font-medium">Status</th>
</tr>
</thead>
<tbody>
{recipients.map((recipient: Record<string, unknown>) => {
const rStatus = String(recipient.status ?? 'pending');
return (
<tr
key={String(recipient.id)}
className="border-b hover:bg-muted/30"
>
<td className="p-3 font-medium">
{String(recipient.name ?? '—')}
</td>
<td className="p-3">
{String(recipient.email ?? '—')}
</td>
<td className="p-3">
<Badge
variant={
RECIPIENT_STATUS_VARIANT[rStatus] ?? 'secondary'
}
>
{RECIPIENT_STATUS_LABEL[rStatus] ?? rStatus}
</Badge>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,125 @@
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Textarea } from '@kit/ui/textarea';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function NewNewsletterPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
return (
<CmsPageShell account={account} title="Neuer Newsletter">
<div className="flex w-full flex-col gap-6">
{/* Back link */}
<div>
<Link
href={`/home/${account}/newsletter`}
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Zurück zu Newsletter
</Link>
</div>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>Neuer Newsletter</CardTitle>
<CardDescription>
Erstellen Sie eine neue Newsletter-Kampagne.
</CardDescription>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-5">
{/* Betreff */}
<div className="flex flex-col gap-2">
<Label htmlFor="subject">Betreff</Label>
<Input
id="subject"
name="subject"
placeholder="z.B. Monatliche Vereinsnachrichten März 2026"
required
/>
</div>
{/* Body HTML */}
<div className="flex flex-col gap-2">
<Label htmlFor="bodyHtml">Inhalt (HTML)</Label>
<Textarea
id="bodyHtml"
name="bodyHtml"
placeholder="<h1>Hallo {{first_name}},</h1>&#10;<p>Neuigkeiten aus dem Verein...</p>"
rows={10}
className="font-mono text-sm"
required
/>
<p className="text-xs text-muted-foreground">
Verwenden Sie {'{{first_name}}'}, {'{{name}}'} und{' '}
{'{{email}}'} als Platzhalter für die Personalisierung.
</p>
</div>
{/* Empfänger Info */}
<div className="rounded-md bg-muted/50 p-4 text-sm text-muted-foreground">
<p>
<strong>Empfänger-Auswahl:</strong> Nach dem Erstellen können
Sie die Empfänger aus Ihrer Mitgliederliste auswählen. Es
werden nur Mitglieder mit hinterlegter E-Mail-Adresse
berücksichtigt.
</p>
</div>
{/* Vorlage Auswahl */}
<div className="flex flex-col gap-2">
<Label htmlFor="templateId">
Vorlage (optional)
</Label>
<select
id="templateId"
name="templateId"
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<option value="">Keine Vorlage</option>
</select>
</div>
</form>
</CardContent>
<CardFooter className="flex justify-between">
<Link href={`/home/${account}/newsletter`}>
<Button variant="outline">Abbrechen</Button>
</Link>
<Button type="submit">Newsletter erstellen</Button>
</CardFooter>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,178 @@
import Link from 'next/link';
import { Mail, Plus, Send, Users } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createNewsletterApi } from '@kit/newsletter/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card';
interface PageProps {
params: Promise<{ account: string }>;
}
const STATUS_BADGE_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
draft: 'secondary',
scheduled: 'default',
sending: 'info',
sent: 'outline',
failed: 'destructive',
};
const STATUS_LABEL: Record<string, string> = {
draft: 'Entwurf',
scheduled: 'Geplant',
sending: 'Wird gesendet',
sent: 'Gesendet',
failed: 'Fehlgeschlagen',
};
export default async function NewsletterPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createNewsletterApi(client);
const newsletters = await api.listNewsletters(acct.id);
const sentCount = newsletters.filter(
(n: Record<string, unknown>) => n.status === 'sent',
).length;
const totalRecipients = newsletters.reduce(
(sum: number, n: Record<string, unknown>) =>
sum + (Number(n.total_recipients) || 0),
0,
);
return (
<CmsPageShell account={account} title="Newsletter">
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Newsletter</h1>
<p className="text-muted-foreground">
Newsletter erstellen und versenden
</p>
</div>
<Link href={`/home/${account}/newsletter/new`}>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neuer Newsletter
</Button>
</Link>
</div>
{/* Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatsCard
title="Newsletter"
value={newsletters.length}
icon={<Mail className="h-5 w-5" />}
/>
<StatsCard
title="Gesendet"
value={sentCount}
icon={<Send className="h-5 w-5" />}
/>
<StatsCard
title="Empfänger gesamt"
value={totalRecipients}
icon={<Users className="h-5 w-5" />}
/>
</div>
{/* Table or Empty State */}
{newsletters.length === 0 ? (
<EmptyState
icon={<Mail className="h-8 w-8" />}
title="Keine Newsletter vorhanden"
description="Erstellen Sie Ihren ersten Newsletter, um loszulegen."
actionLabel="Neuer Newsletter"
actionHref={`/home/${account}/newsletter/new`}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Newsletter ({newsletters.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Betreff</th>
<th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-right font-medium">Empfänger</th>
<th className="p-3 text-left font-medium">Erstellt</th>
<th className="p-3 text-left font-medium">Gesendet</th>
</tr>
</thead>
<tbody>
{newsletters.map((nl: Record<string, unknown>) => (
<tr
key={String(nl.id)}
className="border-b hover:bg-muted/30"
>
<td className="p-3 font-medium">
<Link
href={`/home/${account}/newsletter/${String(nl.id)}`}
className="hover:underline"
>
{String(nl.subject ?? '(Kein Betreff)')}
</Link>
</td>
<td className="p-3">
<Badge
variant={
STATUS_BADGE_VARIANT[String(nl.status)] ?? 'secondary'
}
>
{STATUS_LABEL[String(nl.status)] ?? String(nl.status)}
</Badge>
</td>
<td className="p-3 text-right">
{nl.total_recipients != null
? String(nl.total_recipients)
: '—'}
</td>
<td className="p-3">
{nl.created_at
? new Date(String(nl.created_at)).toLocaleDateString('de-DE')
: '—'}
</td>
<td className="p-3">
{nl.sent_at
? new Date(String(nl.sent_at)).toLocaleDateString('de-DE')
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,118 @@
import Link from 'next/link';
import { FileText, Plus } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createNewsletterApi } from '@kit/newsletter/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function NewsletterTemplatesPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <div>Konto nicht gefunden</div>;
const api = createNewsletterApi(client);
const templates = await api.listTemplates(acct.id);
return (
<CmsPageShell account={account} title="Newsletter-Vorlagen">
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Newsletter-Vorlagen</h1>
<p className="text-muted-foreground">
Wiederverwendbare Vorlagen für Newsletter
</p>
</div>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neue Vorlage
</Button>
</div>
{/* Table or Empty State */}
{templates.length === 0 ? (
<EmptyState
icon={<FileText className="h-8 w-8" />}
title="Keine Vorlagen vorhanden"
description="Erstellen Sie Ihre erste Newsletter-Vorlage, um sie in Kampagnen wiederzuverwenden."
actionLabel="Neue Vorlage"
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Vorlagen ({templates.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">Betreff</th>
<th className="p-3 text-left font-medium">Variablen</th>
</tr>
</thead>
<tbody>
{templates.map((template: Record<string, unknown>) => {
const variables = Array.isArray(template.variables)
? (template.variables as string[])
: [];
return (
<tr
key={String(template.id)}
className="border-b hover:bg-muted/30"
>
<td className="p-3 font-medium">
{String(template.name ?? '—')}
</td>
<td className="p-3">
{String(template.subject ?? '—')}
</td>
<td className="p-3">
<div className="flex flex-wrap gap-1">
{variables.length > 0
? variables.map((v) => (
<Badge
key={v}
variant="secondary"
className="text-xs"
>
{`{{${v}}}`}
</Badge>
))
: <span className="text-muted-foreground"></span>}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);
}

View File

@@ -1,41 +1,381 @@
import { use } from 'react';
import Link from 'next/link';
import { getTranslations } from 'next-intl/server';
import {
ArrowRight,
FileText,
GraduationCap,
Mail,
Plus,
UserCheck,
UserPlus,
CalendarDays,
Activity,
BedDouble,
} from 'lucide-react';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { DashboardDemo } from './_components/dashboard-demo';
import { TeamAccountLayoutPageHeader } from './_components/team-account-layout-page-header';
import { createMemberManagementApi } from '@kit/member-management/api';
import { createCourseManagementApi } from '@kit/course-management/api';
import { createFinanceApi } from '@kit/finance/api';
import { createNewsletterApi } from '@kit/newsletter/api';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { createEventManagementApi } from '@kit/event-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { StatsCard } from '~/components/stats-card';
interface TeamAccountHomePageProps {
params: Promise<{ account: string }>;
}
export const generateMetadata = async () => {
const t = await getTranslations('teams');
const title = t('home.pageTitle');
export default async function TeamAccountHomePage({
params,
}: TeamAccountHomePageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
return {
title,
};
};
const { data: acct } = await client
.from('accounts')
.select('id, name')
.eq('slug', account)
.single();
function TeamAccountHomePage({ params }: TeamAccountHomePageProps) {
const account = use(params).account;
if (!acct) return <div>Konto nicht gefunden</div>;
// Load all stats in parallel with allSettled for resilience
const [
memberStatsResult,
courseStatsResult,
invoicesResult,
newslettersResult,
bookingsResult,
eventsResult,
] = await Promise.allSettled([
createMemberManagementApi(client).getMemberStatistics(acct.id),
createCourseManagementApi(client).getStatistics(acct.id),
createFinanceApi(client).listInvoices(acct.id, { status: 'draft' }),
createNewsletterApi(client).listNewsletters(acct.id),
createBookingManagementApi(client).listBookings(acct.id, { page: 1 }),
createEventManagementApi(client).listEvents(acct.id, { page: 1 }),
]);
const memberStats =
memberStatsResult.status === 'fulfilled'
? memberStatsResult.value
: { total: 0, active: 0, inactive: 0, pending: 0, resigned: 0 };
const courseStats =
courseStatsResult.status === 'fulfilled'
? courseStatsResult.value
: { totalCourses: 0, openCourses: 0, completedCourses: 0, totalParticipants: 0 };
const openInvoices =
invoicesResult.status === 'fulfilled' ? invoicesResult.value : [];
const newsletters =
newslettersResult.status === 'fulfilled' ? newslettersResult.value : [];
const bookings =
bookingsResult.status === 'fulfilled'
? bookingsResult.value
: { data: [], total: 0 };
const events =
eventsResult.status === 'fulfilled'
? eventsResult.value
: { data: [], total: 0 };
const accountName = acct.name ? String(acct.name) : 'Dashboard';
return (
<PageBody>
<TeamAccountLayoutPageHeader
account={account}
title={<Trans i18nKey={'common.routes.dashboard'} />}
description={<AppBreadcrumbs />}
<CmsPageShell account={account} title={accountName}>
<div className="flex w-full flex-col gap-6">
{/* Stats Row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Mitglieder"
value={memberStats.active}
icon={<UserCheck className="h-5 w-5" />}
description={`${memberStats.total} gesamt, ${memberStats.pending} ausstehend`}
/>
<StatsCard
title="Kurse"
value={courseStats.openCourses}
icon={<GraduationCap className="h-5 w-5" />}
description={`${courseStats.totalCourses} gesamt, ${courseStats.totalParticipants} Teilnehmer`}
/>
<StatsCard
title="Offene Rechnungen"
value={openInvoices.length}
icon={<FileText className="h-5 w-5" />}
description="Entwürfe zum Versenden"
/>
<StatsCard
title="Newsletter"
value={newsletters.length}
icon={<Mail className="h-5 w-5" />}
description="Erstellt"
/>
</div>
<DashboardDemo />
</PageBody>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Letzte Aktivität */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Letzte Aktivität
</CardTitle>
<CardDescription>
Aktuelle Buchungen und Veranstaltungen
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Recent bookings */}
{bookings.data.slice(0, 3).map((booking: Record<string, unknown>) => (
<div
key={String(booking.id)}
className="flex items-center justify-between rounded-md border p-3"
>
<div className="flex items-center gap-3">
<div className="rounded-full bg-blue-500/10 p-2 text-blue-600">
<BedDouble className="h-4 w-4" />
</div>
<div>
<Link
href={`/home/${account}/bookings/${String(booking.id)}`}
className="text-sm font-medium hover:underline"
>
Buchung #{String(booking.id).slice(0, 8)}
</Link>
<p className="text-xs text-muted-foreground">
{booking.check_in
? new Date(
String(booking.check_in),
).toLocaleDateString('de-DE')
: '—'}{' '}
{' '}
{booking.check_out
? new Date(
String(booking.check_out),
).toLocaleDateString('de-DE')
: '—'}
</p>
</div>
</div>
<Badge variant="outline">
{String(booking.status ?? '—')}
</Badge>
</div>
))}
{/* Recent events */}
{events.data.slice(0, 3).map((event: Record<string, unknown>) => (
<div
key={String(event.id)}
className="flex items-center justify-between rounded-md border p-3"
>
<div className="flex items-center gap-3">
<div className="rounded-full bg-amber-500/10 p-2 text-amber-600">
<CalendarDays className="h-4 w-4" />
</div>
<div>
<Link
href={`/home/${account}/events/${String(event.id)}`}
className="text-sm font-medium hover:underline"
>
{String(event.name)}
</Link>
<p className="text-xs text-muted-foreground">
{event.event_date
? new Date(
String(event.event_date),
).toLocaleDateString('de-DE')
: 'Kein Datum'}
</p>
</div>
</div>
<Badge variant="outline">
{String(event.status ?? '—')}
</Badge>
</div>
))}
{bookings.data.length === 0 && events.data.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Activity className="h-8 w-8 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
Noch keine Aktivitäten vorhanden
</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Schnellaktionen */}
<Card>
<CardHeader>
<CardTitle>Schnellaktionen</CardTitle>
<CardDescription>Häufig verwendete Aktionen</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<Link href={`/home/${account}/members-cms/new`}>
<Button
variant="outline"
className="w-full justify-between"
>
<span className="flex items-center gap-2">
<UserPlus className="h-4 w-4" />
Neues Mitglied
</span>
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
<Link href={`/home/${account}/courses/new`}>
<Button
variant="outline"
className="w-full justify-between"
>
<span className="flex items-center gap-2">
<GraduationCap className="h-4 w-4" />
Neuer Kurs
</span>
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
<Link href={`/home/${account}/newsletter/new`}>
<Button
variant="outline"
className="w-full justify-between"
>
<span className="flex items-center gap-2">
<Mail className="h-4 w-4" />
Newsletter erstellen
</span>
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
<Link href={`/home/${account}/bookings/new`}>
<Button
variant="outline"
className="w-full justify-between"
>
<span className="flex items-center gap-2">
<BedDouble className="h-4 w-4" />
Neue Buchung
</span>
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
<Link href={`/home/${account}/events/new`}>
<Button
variant="outline"
className="w-full justify-between"
>
<span className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Neue Veranstaltung
</span>
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</CardContent>
</Card>
</div>
{/* Module Overview Row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
Buchungen
</p>
<p className="text-2xl font-bold">{bookings.total}</p>
<p className="text-xs text-muted-foreground">
{bookings.data.filter(
(b: Record<string, unknown>) =>
b.status === 'confirmed' || b.status === 'checked_in',
).length}{' '}
aktiv
</p>
</div>
<Link href={`/home/${account}/bookings`}>
<Button variant="ghost" size="icon">
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
Veranstaltungen
</p>
<p className="text-2xl font-bold">{events.total}</p>
<p className="text-xs text-muted-foreground">
{events.data.filter(
(e: Record<string, unknown>) =>
e.status === 'published' ||
e.status === 'registration_open',
).length}{' '}
aktiv
</p>
</div>
<Link href={`/home/${account}/events`}>
<Button variant="ghost" size="icon">
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
Kurse abgeschlossen
</p>
<p className="text-2xl font-bold">
{courseStats.completedCourses}
</p>
<p className="text-xs text-muted-foreground">
von {courseStats.totalCourses} insgesamt
</p>
</div>
<Link href={`/home/${account}/courses`}>
<Button variant="ghost" size="icon">
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
</div>
</CmsPageShell>
);
}
export default TeamAccountHomePage;

View File

@@ -1,4 +1,8 @@
import '../styles/globals.css';
import { Noto_Serif } from "next/font/google";
const notoSerif = Noto_Serif({subsets:['latin'],variable:'--font-serif'});
export default function RootLayout({ children }: React.PropsWithChildren) {
return children;

25
apps/web/components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-maia",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "styles/globals.css",
"baseColor": "olive",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "~/components",
"utils": "~/lib/utils",
"ui": "~/components/ui",
"lib": "~/lib",
"hooks": "~/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View File

@@ -0,0 +1,33 @@
import type { ReactNode } from 'react';
import { PageBody } from '@kit/ui/page';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { TeamAccountLayoutPageHeader } from '~/home/[account]/_components/team-account-layout-page-header';
interface CmsPageShellProps {
account: string;
title: string;
description?: string;
children: ReactNode;
}
/**
* Shared CMS page shell — wraps PageBody + header + breadcrumbs.
* Use in every CMS feature page to maintain consistent layout.
*/
export function CmsPageShell({ account, title, description, children }: CmsPageShellProps) {
return (
<>
<TeamAccountLayoutPageHeader
account={account}
title={title}
description={description ?? <AppBreadcrumbs />}
/>
<PageBody>
{children}
</PageBody>
</>
);
}

View File

@@ -0,0 +1,39 @@
import { Button } from '@kit/ui/button';
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description: string;
actionLabel?: string;
actionHref?: string;
onAction?: () => void;
}
/**
* Reusable empty state with icon + CTA.
* Used when DataTables have 0 rows.
*/
export function EmptyState({ icon, title, description, actionLabel, actionHref, onAction }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
{icon && (
<div className="mb-4 rounded-full bg-muted p-4 text-muted-foreground">
{icon}
</div>
)}
<h3 className="text-lg font-semibold">{title}</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
{actionLabel && (
<div className="mt-6">
{actionHref ? (
<a href={actionHref}>
<Button>{actionLabel}</Button>
</a>
) : (
<Button onClick={onAction}>{actionLabel}</Button>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { Card, CardContent } from '@kit/ui/card';
interface StatsCardProps {
title: string;
value: string | number;
icon?: React.ReactNode;
description?: string;
trend?: { value: number; label: string };
}
/**
* Reusable stat card with icon + value + label + optional trend.
* Used on dashboard and list pages.
*/
export function StatsCard({ title, value, icon, description, trend }: StatsCardProps) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<p className="text-2xl font-bold">{value}</p>
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
</div>
{icon && (
<div className="rounded-full bg-primary/10 p-3 text-primary">
{icon}
</div>
)}
</div>
{trend && (
<div className="mt-2 flex items-center text-xs">
<span className={trend.value >= 0 ? 'text-green-600' : 'text-red-600'}>
{trend.value >= 0 ? '↑' : '↓'} {Math.abs(trend.value)}%
</span>
<span className="ml-1 text-muted-foreground">{trend.label}</span>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,97 @@
/**
* CMS Billing Plans — Stripe product configuration
* Phase 12: Three tiers for the SaaS product
*/
export const CMS_BILLING_PLANS = {
starter: {
name: 'Starter',
description: 'Für kleine Vereine und Organisationen',
limits: {
modules: 1,
records: 100,
members: 50,
storageGb: 1,
users: 3,
},
features: [
'Module Builder (1 Modul)',
'Mitgliederverwaltung (bis 50)',
'Basisfunktionen',
'Deutsche Oberfläche',
'E-Mail-Support',
],
pricing: {
monthly: { amount: 1900, currency: 'eur' }, // 19,00 €
yearly: { amount: 19000, currency: 'eur' }, // 190,00 € (2 Monate gratis)
},
},
professional: {
name: 'Professional',
description: 'Für aktive Vereine und Volkshochschulen',
limits: {
modules: -1, // unlimited
records: -1,
members: -1,
storageGb: 10,
users: 20,
},
features: [
'Unbegrenzte Module',
'Unbegrenzte Mitglieder',
'Kursverwaltung',
'SEPA-Lastschriften',
'Newsletter',
'Dokumentengenerierung',
'Import/Export',
'Prioritäts-Support',
],
pricing: {
monthly: { amount: 4900, currency: 'eur' }, // 49,00 €
yearly: { amount: 49000, currency: 'eur' }, // 490,00 €
},
},
enterprise: {
name: 'Enterprise',
description: 'Für große Organisationen mit individuellen Anforderungen',
limits: {
modules: -1,
records: -1,
members: -1,
storageGb: 100,
users: -1,
},
features: [
'Alles aus Professional',
'White-Label / eigenes Branding',
'REST-API Zugang',
'Multi-Mandanten-Verwaltung',
'Dedizierte Instanz (optional)',
'Individuelle Anpassungen',
'Persönlicher Ansprechpartner',
'SLA-Garantie',
],
pricing: {
monthly: { amount: 14900, currency: 'eur' }, // 149,00 €
yearly: { amount: 149000, currency: 'eur' }, // 1.490,00 €
},
},
} as const;
export type BillingPlanKey = keyof typeof CMS_BILLING_PLANS;
/**
* Check if a plan allows a given feature/limit.
*/
export function checkPlanLimit(
plan: BillingPlanKey,
limit: keyof typeof CMS_BILLING_PLANS.starter.limits,
currentUsage: number,
): boolean {
const planLimits = CMS_BILLING_PLANS[plan].limits;
const max = planLimits[limit];
if (max === -1) return true; // unlimited
return currentUsage < max;
}

View File

@@ -41,6 +41,15 @@ const FeatureFlagsSchema = z.object({
enableTeamsOnly: z.boolean({
error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY',
}),
// CMS feature flags
enableModuleBuilder: z.boolean().default(true),
enableMemberManagement: z.boolean().default(true),
enableCourseManagement: z.boolean().default(true),
enableBookingManagement: z.boolean().default(false),
enableSepaPayments: z.boolean().default(true),
enableDocumentGeneration: z.boolean().default(true),
enableNewsletter: z.boolean().default(true),
enableGdprCompliance: z.boolean().default(true),
});
const featuresFlagConfig = FeatureFlagsSchema.parse({
@@ -90,6 +99,39 @@ const featuresFlagConfig = FeatureFlagsSchema.parse({
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY,
false,
),
// CMS feature flags
enableModuleBuilder: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_MODULE_BUILDER,
true,
),
enableMemberManagement: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_MEMBER_MANAGEMENT,
true,
),
enableCourseManagement: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_COURSE_MANAGEMENT,
true,
),
enableBookingManagement: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_BOOKING_MANAGEMENT,
false,
),
enableSepaPayments: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_SEPA_PAYMENTS,
true,
),
enableDocumentGeneration: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_DOCUMENT_GENERATION,
true,
),
enableNewsletter: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_NEWSLETTER,
true,
),
enableGdprCompliance: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_GDPR_COMPLIANCE,
true,
),
} satisfies z.output<typeof FeatureFlagsSchema>);
export default featuresFlagConfig;

View File

@@ -22,6 +22,14 @@ const PathsSchema = z.object({
accountProfileSettings: z.string().min(1),
createTeam: z.string().min(1),
joinTeam: z.string().min(1),
// CMS paths
accountModules: z.string().min(1),
accountCmsMembers: z.string().min(1),
accountCourses: z.string().min(1),
accountBookings: z.string().min(1),
accountFinance: z.string().min(1),
accountDocuments: z.string().min(1),
accountNewsletter: z.string().min(1),
}),
});
@@ -47,6 +55,14 @@ const pathsConfig = PathsSchema.parse({
accountProfileSettings: `/home/[account]/settings/profile`,
createTeam: '/home/create-team',
joinTeam: '/join',
// CMS paths
accountModules: `/home/[account]/modules`,
accountCmsMembers: `/home/[account]/members-cms`,
accountCourses: `/home/[account]/courses`,
accountBookings: `/home/[account]/bookings`,
accountFinance: `/home/[account]/finance`,
accountDocuments: `/home/[account]/documents`,
accountNewsletter: `/home/[account]/newsletter`,
},
} satisfies z.output<typeof PathsSchema>);

View File

@@ -1,4 +1,8 @@
import { CreditCard, LayoutDashboard, Settings, Users } from 'lucide-react';
import {
CreditCard, LayoutDashboard, Settings, Users, Database,
UserCheck, GraduationCap, Hotel, Calendar, Wallet,
FileText, Mail,
} from 'lucide-react';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
@@ -17,7 +21,61 @@ const getRoutes = (account: string) => [
Icon: <LayoutDashboard className={iconClasses} />,
highlightMatch: `${pathsConfig.app.home}$`,
},
],
featureFlagsConfig.enableModuleBuilder
? {
label: 'common.routes.modules',
path: createPath(pathsConfig.app.accountModules, account),
Icon: <Database className={iconClasses} />,
}
: undefined,
featureFlagsConfig.enableMemberManagement
? {
label: 'common.routes.cmsMembers',
path: createPath(pathsConfig.app.accountCmsMembers, account),
Icon: <UserCheck className={iconClasses} />,
}
: undefined,
featureFlagsConfig.enableCourseManagement
? {
label: 'common.routes.courses',
path: createPath(pathsConfig.app.accountCourses, account),
Icon: <GraduationCap className={iconClasses} />,
}
: undefined,
featureFlagsConfig.enableBookingManagement
? {
label: 'common.routes.bookings',
path: createPath(pathsConfig.app.accountBookings, account),
Icon: <Hotel className={iconClasses} />,
}
: undefined,
{
label: 'common.routes.events',
path: createPath(`/home/[account]/events`, account),
Icon: <Calendar className={iconClasses} />,
},
featureFlagsConfig.enableSepaPayments
? {
label: 'common.routes.finance',
path: createPath(pathsConfig.app.accountFinance, account),
Icon: <Wallet className={iconClasses} />,
}
: undefined,
featureFlagsConfig.enableDocumentGeneration
? {
label: 'common.routes.documents',
path: createPath(pathsConfig.app.accountDocuments, account),
Icon: <FileText className={iconClasses} />,
}
: undefined,
featureFlagsConfig.enableNewsletter
? {
label: 'common.routes.newsletter',
path: createPath(pathsConfig.app.accountNewsletter, account),
Icon: <Mail className={iconClasses} />,
}
: undefined,
].filter(Boolean),
},
{
label: 'common.routes.settings',

View File

@@ -0,0 +1,149 @@
{
"accountTabLabel": "Kontoeinstellungen",
"accountTabDescription": "Verwalten Sie Ihre Kontoeinstellungen",
"homePage": "Startseite",
"billingTab": "Abrechnung",
"settingsTab": "Einstellungen",
"multiFactorAuth": "Zwei-Faktor-Authentifizierung",
"multiFactorAuthDescription": "Richten Sie eine Zwei-Faktor-Authentifizierung ein, um Ihr Konto zusätzlich zu sichern",
"updateProfileSuccess": "Profil erfolgreich aktualisiert",
"updateProfileError": "Fehler aufgetreten. Bitte versuchen Sie es erneut",
"updatePasswordSuccess": "Passwort erfolgreich aktualisiert",
"updatePasswordSuccessMessage": "Ihr Passwort wurde erfolgreich aktualisiert!",
"updatePasswordError": "Fehler aufgetreten. Bitte versuchen Sie es erneut",
"updatePasswordLoading": "Passwort wird aktualisiert...",
"updateProfileLoading": "Profil wird aktualisiert...",
"name": "Ihr Name",
"nameDescription": "Aktualisieren Sie Ihren Anzeigenamen",
"emailLabel": "E-Mail-Adresse",
"accountImage": "Profilbild",
"accountImageDescription": "Wählen Sie ein Foto für Ihr Profilbild.",
"profilePictureHeading": "Profilbild hochladen",
"profilePictureSubheading": "Wählen Sie ein Foto als Profilbild.",
"updateProfileSubmitLabel": "Profil aktualisieren",
"updatePasswordCardTitle": "Passwort ändern",
"updatePasswordCardDescription": "Ändern Sie Ihr Passwort, um Ihr Konto zu schützen.",
"currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort",
"repeatPassword": "Neues Passwort wiederholen",
"repeatPasswordDescription": "Bitte wiederholen Sie Ihr neues Passwort zur Bestätigung",
"yourPassword": "Ihr Passwort",
"updatePasswordSubmitLabel": "Passwort aktualisieren",
"updateEmailCardTitle": "E-Mail-Adresse ändern",
"updateEmailCardDescription": "Ändern Sie die E-Mail-Adresse für Ihre Anmeldung",
"newEmail": "Neue E-Mail-Adresse",
"repeatEmail": "E-Mail wiederholen",
"updateEmailSubmitLabel": "E-Mail-Adresse aktualisieren",
"updateEmailSuccess": "E-Mail-Aktualisierung angefordert",
"updateEmailSuccessMessage": "Wir haben Ihnen eine E-Mail zur Bestätigung gesendet. Bitte überprüfen Sie Ihren Posteingang.",
"updateEmailLoading": "E-Mail wird aktualisiert...",
"updateEmailError": "E-Mail nicht aktualisiert. Bitte versuchen Sie es erneut",
"passwordNotMatching": "Passwörter stimmen nicht überein",
"emailNotMatching": "E-Mail-Adressen stimmen nicht überein",
"passwordNotChanged": "Ihr Passwort wurde nicht geändert",
"emailsNotMatching": "E-Mail-Adressen stimmen nicht überein",
"cannotUpdatePassword": "Sie können Ihr Passwort nicht ändern, da Ihr Konto nicht mit einer E-Mail verknüpft ist.",
"setupMfaButtonLabel": "Neuen Faktor einrichten",
"multiFactorSetupErrorHeading": "Einrichtung fehlgeschlagen",
"multiFactorSetupErrorDescription": "Bei der Einrichtung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
"multiFactorAuthHeading": "Sichern Sie Ihr Konto mit Zwei-Faktor-Authentifizierung",
"multiFactorModalHeading": "Scannen Sie den QR-Code mit Ihrer Authenticator-App. Geben Sie dann den generierten Code ein.",
"factorNameLabel": "Ein einprägsamer Name für diesen Faktor",
"factorNameHint": "Verwenden Sie einen leicht zu merkenden Namen, z.B. iPhone 14",
"factorNameSubmitLabel": "Faktor benennen",
"unenrollTooltip": "Faktor entfernen",
"unenrollingFactor": "Faktor wird entfernt...",
"unenrollFactorSuccess": "Faktor erfolgreich entfernt",
"unenrollFactorError": "Faktor konnte nicht entfernt werden",
"factorsListError": "Fehler beim Laden der Faktoren",
"factorsListErrorDescription": "Faktoren konnten nicht geladen werden. Bitte versuchen Sie es erneut.",
"factorName": "Faktorname",
"factorType": "Typ",
"factorStatus": "Status",
"mfaEnabledSuccessTitle": "Zwei-Faktor-Authentifizierung ist aktiviert",
"mfaEnabledSuccessDescription": "Herzlichen Glückwunsch! Die Zwei-Faktor-Authentifizierung wurde erfolgreich eingerichtet.",
"verificationCode": "Bestätigungscode",
"addEmailAddress": "E-Mail-Adresse hinzufügen",
"verifyActivationCodeDescription": "Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein",
"loadingFactors": "Faktoren werden geladen...",
"enableMfaFactor": "Faktor aktivieren",
"disableMfaFactor": "Faktor deaktivieren",
"qrCodeErrorHeading": "QR-Code-Fehler",
"qrCodeErrorDescription": "QR-Code konnte nicht generiert werden",
"multiFactorSetupSuccess": "Faktor erfolgreich eingerichtet",
"submitVerificationCode": "Bestätigungscode absenden",
"mfaEnabledSuccessAlert": "Zwei-Faktor-Authentifizierung ist aktiviert",
"verifyingCode": "Code wird überprüft...",
"invalidVerificationCodeHeading": "Ungültiger Bestätigungscode",
"invalidVerificationCodeDescription": "Der eingegebene Code ist ungültig. Bitte versuchen Sie es erneut.",
"unenrollFactorModalHeading": "Faktor entfernen",
"unenrollFactorModalDescription": "Sie entfernen diesen Faktor. Er kann dann nicht mehr zur Anmeldung verwendet werden.",
"unenrollFactorModalBody": "Sie entfernen diesen Faktor. Er kann dann nicht mehr zur Anmeldung verwendet werden.",
"unenrollFactorModalButtonLabel": "Ja, Faktor entfernen",
"selectFactor": "Wählen Sie einen Faktor zur Identitätsbestätigung",
"disableMfa": "Zwei-Faktor-Authentifizierung deaktivieren",
"disableMfaButtonLabel": "2FA deaktivieren",
"confirmDisableMfaButtonLabel": "Ja, 2FA deaktivieren",
"disablingMfa": "Zwei-Faktor-Authentifizierung wird deaktiviert...",
"disableMfaSuccess": "Zwei-Faktor-Authentifizierung erfolgreich deaktiviert",
"disableMfaError": "2FA konnte nicht deaktiviert werden.",
"sendingEmailVerificationLink": "E-Mail wird gesendet...",
"sendEmailVerificationLinkSuccess": "Bestätigungslink erfolgreich gesendet",
"sendEmailVerificationLinkError": "E-Mail konnte nicht gesendet werden",
"sendVerificationLinkSubmitLabel": "Bestätigungslink senden",
"sendVerificationLinkSuccessLabel": "E-Mail gesendet! Überprüfen Sie Ihren Posteingang",
"verifyEmailAlertHeading": "Bitte bestätigen Sie Ihre E-Mail für 2FA",
"verificationLinkAlertDescription": "Ihre E-Mail ist noch nicht bestätigt. Bitte bestätigen Sie Ihre E-Mail, um die Zwei-Faktor-Authentifizierung einrichten zu können.",
"authFactorName": "Faktorname (optional)",
"authFactorNameHint": "Vergeben Sie einen einprägsamen Namen",
"loadingUser": "Benutzerdaten werden geladen...",
"linkPhoneNumber": "Telefonnummer verknüpfen",
"dangerZone": "Gefahrenbereich",
"dangerZoneDescription": "Einige Aktionen können nicht rückgängig gemacht werden.",
"deleteAccount": "Konto löschen",
"deletingAccount": "Konto wird gelöscht...",
"deleteAccountDescription": "Ihr Konto und alle zugehörigen Daten werden unwiderruflich gelöscht. Aktive Abonnements werden sofort gekündigt.",
"deleteProfileConfirmationInputLabel": "Geben Sie LÖSCHEN zur Bestätigung ein",
"deleteAccountErrorHeading": "Konto konnte nicht gelöscht werden",
"needsReauthentication": "Erneute Authentifizierung erforderlich",
"needsReauthenticationDescription": "Bitte melden Sie sich ab und wieder an, um Ihr Passwort zu ändern.",
"language": "Sprache",
"languageDescription": "Wählen Sie Ihre bevorzugte Sprache",
"noTeamsYet": "Sie haben noch keine Teams.",
"createTeam": "Erstellen Sie ein Team, um loszulegen.",
"createTeamButtonLabel": "Team erstellen",
"linkedAccounts": "Verknüpfte Konten",
"linkedAccountsDescription": "Weitere Anmeldeanbieter verknüpfen",
"unlinkAccountButton": "{provider} trennen",
"unlinkAccountSuccess": "Konto getrennt",
"unlinkAccountError": "Trennung fehlgeschlagen",
"linkAccountSuccess": "Konto verknüpft",
"linkAccountError": "Verknüpfung fehlgeschlagen",
"linkEmailPasswordButton": "E-Mail & Passwort hinzufügen",
"linkEmailPasswordSuccess": "E-Mail und Passwort verknüpft",
"linkEmailPasswordError": "Verknüpfung fehlgeschlagen",
"linkingAccount": "Konto wird verknüpft...",
"accountLinked": "Verknüpfungsanfrage gesendet. Bitte warten...",
"unlinkAccount": "Konto trennen",
"failedToLinkAccount": "Verknüpfung fehlgeschlagen",
"availableMethods": "Verfügbare Methoden",
"availableMethodsDescription": "Verknüpfen Sie Ihr Konto mit einer oder mehreren der folgenden Methoden",
"linkedMethods": "Verknüpfte Anmeldemethoden",
"alreadyLinkedMethodsDescription": "Sie haben diese Konten bereits verknüpft",
"confirmUnlinkAccount": "Sie trennen diesen Anbieter.",
"unlinkAccountConfirmation": "Möchten Sie diesen Anbieter wirklich von Ihrem Konto trennen? Diese Aktion kann nicht rückgängig gemacht werden.",
"unlinkingAccount": "Konto wird getrennt...",
"accountUnlinked": "Konto erfolgreich getrennt",
"linkEmailPassword": "E-Mail & Passwort",
"linkEmailPasswordDescription": "Passwort-Authentifizierung hinzufügen",
"noAccountsAvailable": "Derzeit keine weitere Methode verfügbar",
"linkAccountDescription": "Mit {provider} verknüpfen",
"updatePasswordDescription": "Passwort-Authentifizierung hinzufügen",
"setEmailAddress": "E-Mail-Adresse festlegen",
"setEmailDescription": "Eine E-Mail-Adresse zu Ihrem Konto hinzufügen",
"setEmailSuccess": "E-Mail erfolgreich festgelegt",
"setEmailSuccessMessage": "Wir haben Ihnen eine Bestätigungs-E-Mail gesendet.",
"setEmailLoading": "E-Mail wird festgelegt...",
"setEmailError": "E-Mail konnte nicht festgelegt werden",
"emailNotChanged": "Ihre E-Mail-Adresse wurde nicht geändert"
}

View File

@@ -0,0 +1,119 @@
{
"signUpHeading": "Konto erstellen",
"signUp": "Registrieren",
"signUpSubheading": "Füllen Sie das Formular aus, um ein Konto zu erstellen.",
"signInHeading": "In Ihr Konto einloggen",
"signInSubheading": "Willkommen zurück! Bitte geben Sie Ihre Daten ein",
"signIn": "Anmelden",
"getStarted": "Jetzt starten",
"updatePassword": "Passwort aktualisieren",
"signOut": "Abmelden",
"signingIn": "Wird angemeldet...",
"signingUp": "Wird registriert...",
"verifyingCaptcha": "Überprüfung läuft...",
"doNotHaveAccountYet": "Noch kein Konto?",
"alreadyHaveAnAccount": "Bereits ein Konto?",
"signUpToAcceptInvite": "Bitte melden Sie sich an, um die Einladung anzunehmen",
"clickToAcceptAs": "Klicken Sie auf die Schaltfläche, um die Einladung als <b>{email}</b> anzunehmen",
"acceptInvite": "Einladung annehmen",
"acceptingInvite": "Einladung wird angenommen...",
"acceptInviteSuccess": "Einladung erfolgreich angenommen",
"acceptInviteError": "Fehler beim Annehmen der Einladung",
"acceptInviteWithDifferentAccount": "Möchten Sie die Einladung mit einem anderen Konto annehmen?",
"alreadyHaveAccountStatement": "Ich habe bereits ein Konto und möchte mich anmelden",
"doNotHaveAccountStatement": "Ich habe noch kein Konto und möchte mich registrieren",
"signInWithProvider": "Mit {provider} anmelden",
"signInWithPhoneNumber": "Mit Telefonnummer anmelden",
"signInWithEmail": "Mit E-Mail anmelden",
"signUpWithEmail": "Mit E-Mail registrieren",
"passwordHint": "Mindestens 8 Zeichen",
"repeatPasswordDescription": "Passwort erneut eingeben",
"repeatPassword": "Passwort wiederholen",
"passwordForgottenQuestion": "Passwort vergessen?",
"passwordResetLabel": "Passwort zurücksetzen",
"passwordResetSubheading": "Geben Sie Ihre E-Mail-Adresse ein. Sie erhalten einen Link zum Zurücksetzen Ihres Passworts.",
"passwordResetSuccessMessage": "Überprüfen Sie Ihren Posteingang! Wir haben Ihnen einen Link zum Zurücksetzen gesendet.",
"passwordRecoveredQuestion": "Passwort wiederhergestellt?",
"passwordLengthError": "Bitte geben Sie ein Passwort mit mindestens 6 Zeichen an",
"sendEmailLink": "E-Mail-Link senden",
"sendingEmailLink": "E-Mail-Link wird gesendet...",
"sendLinkSuccessDescription": "Überprüfen Sie Ihre E-Mail, wir haben Ihnen einen Link gesendet.",
"sendLinkSuccess": "Wir haben Ihnen einen Link per E-Mail gesendet",
"sendLinkSuccessToast": "Link erfolgreich gesendet",
"getNewLink": "Neuen Link anfordern",
"verifyCodeHeading": "Konto bestätigen",
"verificationCode": "Bestätigungscode",
"verificationCodeHint": "Geben Sie den per SMS gesendeten Code ein",
"verificationCodeSubmitButtonLabel": "Bestätigungscode absenden",
"sendingMfaCode": "Bestätigungscode wird gesendet...",
"verifyingMfaCode": "Code wird überprüft...",
"sendMfaCodeError": "Code konnte nicht gesendet werden",
"verifyMfaCodeSuccess": "Code bestätigt! Sie werden angemeldet...",
"verifyMfaCodeError": "Der Code scheint nicht korrekt zu sein",
"reauthenticate": "Erneut authentifizieren",
"reauthenticateDescription": "Aus Sicherheitsgründen müssen Sie sich erneut authentifizieren",
"errorAlertHeading": "Authentifizierung fehlgeschlagen",
"emailConfirmationAlertHeading": "Bestätigungs-E-Mail gesendet.",
"emailConfirmationAlertBody": "Willkommen! Bitte überprüfen Sie Ihre E-Mail und klicken Sie auf den Link zur Bestätigung.",
"resendLink": "Link erneut senden",
"resendLinkSuccessDescription": "Wir haben Ihnen einen neuen Link gesendet!",
"resendLinkSuccess": "Überprüfen Sie Ihre E-Mail!",
"authenticationErrorAlertHeading": "Authentifizierungsfehler",
"authenticationErrorAlertBody": "Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"sendEmailCode": "Code per E-Mail erhalten",
"sendingEmailCode": "Code wird gesendet...",
"resetPasswordError": "Passwort konnte nicht zurückgesetzt werden. Bitte versuchen Sie es erneut.",
"emailPlaceholder": "ihre@email.de",
"inviteAlertHeading": "Sie wurden in ein Team eingeladen",
"inviteAlertBody": "Bitte melden Sie sich an oder registrieren Sie sich, um die Einladung anzunehmen.",
"acceptTermsAndConditions": "Ich akzeptiere die <TermsOfServiceLink /> und die <PrivacyPolicyLink />",
"termsOfService": "Nutzungsbedingungen",
"privacyPolicy": "Datenschutzerklärung",
"orContinueWith": "Oder fortfahren mit",
"redirecting": "Sie sind drin! Bitte warten...",
"lastUsedMethodPrefix": "Zuletzt angemeldet mit",
"methodPassword": "E-Mail und Passwort",
"methodOtp": "OTP-Code",
"methodMagicLink": "E-Mail-Link",
"methodOauth": "Social Login",
"methodOauthWithProvider": "<provider>{provider}</provider>",
"methodDefault": "einer anderen Methode",
"existingAccountHint": "Sie haben sich zuvor mit <method>{method}</method> angemeldet. <signInLink>Bereits ein Konto?</signInLink>",
"linkAccountToSignIn": "Konto zum Anmelden verknüpfen",
"linkAccountToSignInDescription": "Fügen Sie eine oder mehrere Anmeldemethoden hinzu",
"noIdentityLinkedTitle": "Keine Anmeldemethode hinzugefügt",
"noIdentityLinkedDescription": "Sie haben noch keine Anmeldemethode hinzugefügt. Möchten Sie wirklich fortfahren? Sie können Anmeldemethoden später in Ihren Kontoeinstellungen einrichten.",
"errors": {
"invalid_credentials": "Die eingegebenen Anmeldedaten sind ungültig",
"Invalid login credentials": "Die eingegebenen Anmeldedaten sind ungültig",
"user_already_exists": "Diese Anmeldedaten werden bereits verwendet. Bitte versuchen Sie es mit anderen.",
"User already registered": "Diese Anmeldedaten werden bereits verwendet.",
"email_not_confirmed": "Bitte bestätigen Sie Ihre E-Mail-Adresse vor der Anmeldung",
"Email not confirmed": "Bitte bestätigen Sie Ihre E-Mail-Adresse vor der Anmeldung",
"user_banned": "Dieses Konto wurde gesperrt. Bitte kontaktieren Sie den Support.",
"default": "Ein Fehler ist aufgetreten. Bitte stellen Sie sicher, dass Sie eine Internetverbindung haben, und versuchen Sie es erneut",
"generic": "Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"linkTitle": "Anmeldung fehlgeschlagen",
"linkDescription": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"codeVerifierMismatch": "Sie verwenden einen anderen Browser als beim Anfordern des Anmeldelinks. Bitte verwenden Sie denselben Browser.",
"minPasswordLength": "Passwort muss mindestens 8 Zeichen lang sein",
"passwordsDoNotMatch": "Passwörter stimmen nicht überein",
"minPasswordNumbers": "Passwort muss mindestens eine Zahl enthalten",
"minPasswordSpecialChars": "Passwort muss mindestens ein Sonderzeichen enthalten",
"signup_disabled": "Registrierungen sind derzeit nicht möglich. Bitte kontaktieren Sie den Support.",
"Signups not allowed for otp": "OTP ist deaktiviert. Bitte aktivieren Sie es in Ihren Kontoeinstellungen.",
"uppercasePassword": "Passwort muss mindestens einen Großbuchstaben enthalten",
"insufficient_aal": "Bitte melden Sie sich mit Ihrer Zwei-Faktor-Authentifizierung an",
"otp_expired": "Der E-Mail-Link ist abgelaufen. Bitte versuchen Sie es erneut.",
"same_password": "Das Passwort darf nicht mit dem aktuellen übereinstimmen",
"weakPassword": {
"title": "Passwort ist zu schwach",
"description": "Ihr Passwort erfüllt nicht die Sicherheitsanforderungen:",
"reasons": {
"length": "Passwort muss mindestens 8 Zeichen lang sein",
"characters": "Passwort muss Klein-, Großbuchstaben, Zahlen und Sonderzeichen enthalten",
"pwned": "Dieses Passwort wurde in einem Datenleck gefunden. Bitte wählen Sie ein anderes"
}
}
}
}

View File

@@ -0,0 +1,127 @@
{
"units": {
"member_one": "Mitglied",
"member_other": "Mitglieder"
},
"subscriptionTabSubheading": "Abonnement und Abrechnung verwalten",
"planCardTitle": "Ihr Tarif",
"planCardDescription": "Nachfolgend die Details Ihres aktuellen Tarifs. Sie können Ihren Tarif jederzeit ändern oder kündigen.",
"planRenewal": "Verlängert sich alle {interval} für {price}",
"planDetails": "Tarifdetails",
"checkout": "Zur Kasse",
"trialAlertTitle": "Ihre Testphase endet bald",
"trialAlertDescription": "Ihre Testphase endet am {date}. Wechseln Sie zu einem kostenpflichtigen Tarif, um alle Funktionen weiterhin nutzen zu können.",
"billingPortalCardButton": "Abrechnungsportal öffnen",
"billingPortalCardTitle": "Abrechnungsdaten verwalten",
"billingPortalCardDescription": "Öffnen Sie das Abrechnungsportal, um Ihr Abonnement zu verwalten, Ihren Tarif zu ändern oder Rechnungen herunterzuladen.",
"cancelAtPeriodEndDescription": "Ihr Abonnement wird am {endDate} gekündigt.",
"renewAtPeriodEndDescription": "Ihr Abonnement wird am {endDate} verlängert",
"noPermissionsAlertHeading": "Keine Berechtigung zur Änderung der Abrechnungseinstellungen",
"noPermissionsAlertBody": "Bitte kontaktieren Sie den Kontoinhaber.",
"checkoutSuccessTitle": "Fertig! Alles eingerichtet.",
"checkoutSuccessDescription": "Vielen Dank für Ihr Abonnement. Eine Bestätigungsmail wird an {customerEmail} gesendet.",
"checkoutSuccessBackButton": "Zur Anwendung",
"cannotManageBillingAlertTitle": "Abrechnung kann nicht verwaltet werden",
"cannotManageBillingAlertDescription": "Sie haben keine Berechtigung zur Verwaltung der Abrechnung. Bitte kontaktieren Sie den Kontoinhaber.",
"manageTeamPlan": "Team-Tarif verwalten",
"manageTeamPlanDescription": "Wählen Sie einen Tarif, der zu Ihrem Team passt. Sie können jederzeit upgraden oder downgraden.",
"basePlan": "Basistarif",
"billingInterval": {
"label": "Abrechnungszeitraum wählen",
"month": "Monatliche Abrechnung",
"year": "Jährliche Abrechnung"
},
"perMonth": "Monat",
"custom": "Individueller Tarif",
"lifetime": "Einmalig",
"trialPeriod": "{period} Tage Testphase",
"perPeriod": "pro {period}",
"redirectingToPayment": "Weiterleitung zur Bezahlung...",
"proceedToPayment": "Zur Bezahlung",
"startTrial": "Testphase starten",
"perTeamMember": "Pro Teammitglied",
"perUnitShort": "Pro {unit}",
"perUnit": "Pro {unit} Nutzung",
"teamMembers": "Teammitglieder",
"includedUpTo": "Bis zu {upTo} {unit} inklusive",
"fromPreviousTierUpTo": "für jede(n) {unit} für die nächsten {upTo} {unitPlural}",
"andAbove": "ab {previousTier} {unit}",
"startingAtPriceUnit": "Ab {price}/{unit}",
"priceUnit": "{price}/{unit}",
"forEveryUnit": "für jede(n) {unit}",
"setupFee": "zzgl. {setupFee} Einrichtungsgebühr",
"perUnitIncluded": "({included} inklusive)",
"features": "Funktionen",
"featuresLabel": "Funktionen",
"detailsLabel": "Details",
"planPickerLabel": "Tarif auswählen",
"planCardLabel": "Tarif verwalten",
"planPickerAlertErrorTitle": "Fehler bei der Bezahlung",
"planPickerAlertErrorDescription": "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.",
"subscriptionCancelled": "Abonnement gekündigt",
"cancelSubscriptionDate": "Ihr Abonnement wird zum Ende des Abrechnungszeitraums am {date} gekündigt",
"noPlanChosen": "Bitte wählen Sie einen Tarif",
"noIntervalPlanChosen": "Bitte wählen Sie einen Abrechnungszeitraum",
"status": {
"free": {
"badge": "Kostenlos",
"heading": "Sie nutzen den kostenlosen Tarif",
"description": "Sie nutzen den kostenlosen Tarif. Sie können jederzeit auf einen kostenpflichtigen Tarif wechseln."
},
"active": {
"badge": "Aktiv",
"heading": "Ihr Abonnement ist aktiv",
"description": "Ihr Abonnement ist aktiv. Sie können es im Kundenportal verwalten."
},
"trialing": {
"badge": "Testphase",
"heading": "Sie sind in der Testphase",
"description": "Genießen Sie die Vorteile des Tarifs bis zum Ende der Testphase"
},
"past_due": {
"badge": "Überfällig",
"heading": "Ihre Rechnung ist überfällig",
"description": "Ihre Rechnung ist überfällig. Bitte aktualisieren Sie Ihre Zahlungsmethode."
},
"canceled": {
"badge": "Gekündigt",
"heading": "Ihr Abonnement ist gekündigt",
"description": "Ihr Abonnement wird zum Ende des Abrechnungszeitraums beendet."
},
"unpaid": {
"badge": "Unbezahlt",
"heading": "Ihre Rechnung ist unbezahlt",
"description": "Bitte aktualisieren Sie Ihre Zahlungsmethode."
},
"incomplete": {
"badge": "Ausstehend",
"heading": "Wir warten auf Ihre Zahlung",
"description": "Ihre Zahlung wird verarbeitet. Bitte haben Sie Geduld."
},
"incomplete_expired": {
"badge": "Abgelaufen",
"heading": "Ihre Zahlung ist abgelaufen",
"description": "Bitte aktualisieren Sie Ihre Zahlungsmethode."
},
"paused": {
"badge": "Pausiert",
"heading": "Ihr Abonnement ist pausiert",
"description": "Sie können es jederzeit wieder aktivieren."
},
"succeeded": {
"badge": "Erfolgreich",
"heading": "Zahlung erfolgreich",
"description": "Ihre Zahlung war erfolgreich. Vielen Dank!"
},
"pending": {
"badge": "Ausstehend",
"heading": "Ihre Zahlung ist ausstehend",
"description": "Ihre Zahlung wird verarbeitet."
},
"failed": {
"badge": "Fehlgeschlagen",
"heading": "Ihre Zahlung ist fehlgeschlagen",
"description": "Bitte aktualisieren Sie Ihre Zahlungsmethode."
}
}
}

View File

@@ -0,0 +1,271 @@
{
"modules": {
"title": "Module",
"description": "Verwalten Sie Ihre Datenmodule",
"newModule": "Neues Modul",
"editModule": "Modul bearbeiten",
"deleteModule": "Modul löschen",
"moduleSettings": "Moduleinstellungen",
"moduleName": "Modulname",
"moduleDescription": "Beschreibung",
"moduleStatus": "Status",
"noModules": "Keine Module vorhanden",
"createFirstModule": "Erstellen Sie Ihr erstes Modul, um Daten zu verwalten.",
"fields": "Felder",
"records": "Datensätze",
"import": "Importieren",
"export": "Exportieren",
"print": "Drucken",
"copy": "Kopieren",
"lock": "Sperren",
"unlock": "Entsperren",
"bulkEdit": "Massenbearbeitung",
"search": "Suchen",
"filter": "Filtern",
"advancedFilter": "Erweiterter Filter",
"clearFilters": "Filter zurücksetzen",
"noRecords": "Keine Datensätze gefunden",
"newRecord": "Neuer Datensatz",
"editRecord": "Datensatz bearbeiten",
"deleteRecord": "Datensatz löschen",
"confirmDelete": "Sind Sie sicher, dass Sie diesen Datensatz löschen möchten?",
"recordSaved": "Datensatz erfolgreich gespeichert",
"recordDeleted": "Datensatz erfolgreich gelöscht",
"recordLocked": "Datensatz gesperrt",
"recordUnlocked": "Datensatz entsperrt",
"validationError": "Bitte überprüfen Sie Ihre Eingaben",
"requiredField": "Pflichtfeld",
"importTitle": "Daten importieren",
"importDescription": "Laden Sie eine CSV- oder Excel-Datei hoch",
"importMapping": "Spaltenzuordnung",
"importPreview": "Vorschau",
"importCommit": "Import durchführen",
"importSuccess": "{count} Datensätze erfolgreich importiert",
"importError": "Fehler beim Import",
"exportTitle": "Daten exportieren",
"exportFormat": "Exportformat",
"exportColumns": "Spalten auswählen",
"exportAll": "Alle exportieren",
"exportSelected": "Ausgewählte exportieren",
"designer": "Modul-Designer",
"designerDescription": "Module und Felder konfigurieren"
},
"fieldTypes": {
"text": "Text",
"textarea": "Textbereich",
"richtext": "Formatierter Text",
"checkbox": "Kontrollkästchen",
"radio": "Optionsfeld",
"hidden": "Versteckt",
"select": "Auswahl",
"password": "Passwort",
"file": "Datei",
"date": "Datum",
"time": "Uhrzeit",
"decimal": "Dezimalzahl",
"integer": "Ganzzahl",
"email": "E-Mail",
"phone": "Telefon",
"url": "URL",
"currency": "Währung",
"iban": "IBAN",
"color": "Farbe",
"computed": "Berechnet"
},
"accountSettings": {
"title": "Organisationseinstellungen",
"description": "Verwalten Sie die Einstellungen Ihrer Organisation",
"orgName": "Organisationsname",
"orgAddress": "Adresse",
"orgPostalCode": "Postleitzahl",
"orgCity": "Stadt",
"orgPhone": "Telefon",
"orgEmail": "E-Mail",
"orgWebsite": "Webseite",
"orgChairman": "Vorsitzende(r)",
"accountType": "Kontotyp",
"accountTypes": {
"verein": "Verein",
"vhs": "Volkshochschule",
"hotel": "Hotel",
"kommune": "Kommune",
"generic": "Allgemein"
},
"branding": "Branding",
"logoUrl": "Logo-URL",
"primaryColor": "Primärfarbe",
"secondaryColor": "Sekundärfarbe",
"sepaSettings": "SEPA-Einstellungen",
"creditorId": "Gläubiger-ID",
"iban": "IBAN",
"bic": "BIC",
"emailSettings": "E-Mail-Einstellungen",
"emailSenderName": "Absendername",
"emailFooter": "E-Mail-Fußzeile",
"saved": "Einstellungen gespeichert"
},
"members": {
"title": "Mitglieder",
"description": "Vereinsmitglieder verwalten",
"newMember": "Neues Mitglied",
"memberNumber": "Mitgliedsnummer",
"firstName": "Vorname",
"lastName": "Nachname",
"dateOfBirth": "Geburtsdatum",
"address": "Adresse",
"postalCode": "PLZ",
"city": "Ort",
"phone": "Telefon",
"email": "E-Mail",
"memberSince": "Mitglied seit",
"status": "Status",
"dues": "Beiträge",
"applications": "Anträge",
"cards": "Ausweise",
"statistics": "Statistiken",
"sepaMandate": "SEPA-Mandat",
"gdprConsent": "Datenschutz-Einwilligung"
},
"courses": {
"title": "Kurse",
"description": "Kursangebot verwalten",
"newCourse": "Neuer Kurs",
"courseNumber": "Kursnummer",
"courseName": "Kursname",
"instructor": "Dozent/in",
"location": "Veranstaltungsort",
"category": "Kategorie",
"startDate": "Beginn",
"endDate": "Ende",
"sessions": "Termine",
"participants": "Teilnehmer",
"capacity": "Kapazität",
"waitlist": "Warteliste",
"fee": "Gebühr",
"calendar": "Kalender",
"attendance": "Anwesenheit",
"enrollment": "Anmeldung",
"enrollmentSuccess": "Anmeldung erfolgreich",
"enrollmentCancelled": "Anmeldung storniert"
},
"bookings": {
"title": "Buchungen",
"description": "Zimmer und Buchungen verwalten",
"newBooking": "Neue Buchung",
"rooms": "Zimmer",
"guests": "Gäste",
"checkIn": "Anreise",
"checkOut": "Abreise",
"roomType": "Zimmertyp",
"availability": "Verfügbarkeit",
"price": "Preis",
"extras": "Extras",
"calendar": "Belegungskalender"
},
"events": {
"title": "Veranstaltungen",
"description": "Veranstaltungen und Ferienprogramme verwalten",
"newEvent": "Neue Veranstaltung",
"registrations": "Anmeldungen",
"holidayPasses": "Ferienpässe",
"eventDate": "Datum",
"eventLocation": "Ort",
"capacity": "Plätze"
},
"finance": {
"title": "Finanzen",
"description": "SEPA-Einzüge und Rechnungen verwalten",
"sepa": "SEPA-Lastschriften",
"invoices": "Rechnungen",
"payments": "Zahlungen",
"newBatch": "Neuer Einzug",
"newInvoice": "Neue Rechnung",
"batchStatus": "Einzugsstatus",
"executionDate": "Ausführungsdatum",
"totalAmount": "Gesamtbetrag",
"invoiceNumber": "Rechnungsnummer",
"ibanValidation": "IBAN-Prüfung"
},
"documents": {
"title": "Dokumente",
"description": "Dokumente erstellen und verwalten",
"templates": "Vorlagen",
"generate": "Generieren",
"memberCards": "Mitgliedsausweise",
"labels": "Etiketten",
"reports": "Berichte"
},
"newsletter": {
"title": "Newsletter",
"description": "Newsletter erstellen und versenden",
"newCampaign": "Neue Kampagne",
"templates": "Vorlagen",
"recipients": "Empfänger",
"preview": "Vorschau",
"send": "Senden",
"statistics": "Statistiken",
"dispatching": "Wird versendet..."
},
"gdpr": {
"title": "Datenschutz (DSGVO)",
"register": "Verarbeitungsverzeichnis",
"purpose": "Zweck",
"legalBasis": "Rechtsgrundlage",
"dataCategories": "Datenkategorien",
"dataSubjects": "Betroffene Personen",
"retentionPeriod": "Aufbewahrungsfrist",
"legalBases": {
"consent": "Einwilligung",
"contract": "Vertrag",
"legal_obligation": "Rechtliche Verpflichtung",
"vital_interest": "Lebenswichtiges Interesse",
"public_interest": "Öffentliches Interesse",
"legitimate_interest": "Berechtigtes Interesse"
}
},
"audit": {
"title": "Protokoll",
"description": "Änderungsprotokoll einsehen",
"action": "Aktion",
"user": "Benutzer",
"table": "Tabelle",
"timestamp": "Zeitpunkt",
"oldData": "Vorher",
"newData": "Nachher",
"actions": {
"insert": "Erstellt",
"update": "Geändert",
"delete": "Gelöscht",
"lock": "Gesperrt"
}
},
"permissions": {
"modules.read": "Module lesen",
"modules.write": "Module bearbeiten",
"modules.delete": "Module löschen",
"modules.insert": "Datensätze erstellen",
"modules.lock": "Datensätze sperren",
"modules.import": "Daten importieren",
"modules.export": "Daten exportieren",
"modules.print": "Drucken",
"modules.manage": "Module verwalten",
"members.read": "Mitglieder lesen",
"members.write": "Mitglieder bearbeiten",
"courses.read": "Kurse lesen",
"courses.write": "Kurse bearbeiten",
"bookings.read": "Buchungen lesen",
"bookings.write": "Buchungen bearbeiten",
"finance.read": "Finanzen lesen",
"finance.write": "Finanzen bearbeiten",
"finance.sepa": "SEPA-Einzüge ausführen",
"documents.generate": "Dokumente generieren",
"newsletter.send": "Newsletter versenden"
},
"status": {
"active": "Aktiv",
"inactive": "Inaktiv",
"archived": "Archiviert",
"locked": "Gesperrt",
"deleted": "Gelöscht"
}
}

View File

@@ -0,0 +1,123 @@
{
"homeTabLabel": "Startseite",
"homeTabDescription": "Willkommen auf Ihrer Startseite",
"accountMembers": "Teammitglieder",
"membersTabDescription": "Hier können Sie die Mitglieder Ihres Teams verwalten.",
"billingTabLabel": "Abrechnung",
"billingTabDescription": "Abonnement und Abrechnung verwalten",
"dashboardTabLabel": "Dashboard",
"settingsTabLabel": "Einstellungen",
"profileSettingsTabLabel": "Profil",
"subscriptionSettingsTabLabel": "Abonnement",
"dashboardTabDescription": "Ein Überblick über die Aktivitäten und Leistung Ihres Kontos.",
"settingsTabDescription": "Verwalten Sie Ihre Einstellungen und Präferenzen.",
"emailAddress": "E-Mail-Adresse",
"password": "Passwort",
"modalConfirmationQuestion": "Sind Sie sicher, dass Sie fortfahren möchten?",
"imageInputLabel": "Klicken Sie hier, um ein Bild hochzuladen",
"cancel": "Abbrechen",
"clear": "Löschen",
"notFound": "Nicht gefunden",
"backToHomePage": "Zurück zur Startseite",
"goBack": "Erneut versuchen",
"genericServerError": "Entschuldigung, ein Fehler ist aufgetreten.",
"genericServerErrorHeading": "Entschuldigung, bei der Verarbeitung Ihrer Anfrage ist ein Fehler aufgetreten. Bitte kontaktieren Sie uns, wenn das Problem weiterhin besteht.",
"pageNotFound": "Seite nicht gefunden",
"pageNotFoundSubHeading": "Die gesuchte Seite existiert nicht oder wurde verschoben. Überprüfen Sie die URL oder kehren Sie zur Startseite zurück.",
"genericError": "Etwas ist schiefgelaufen",
"genericErrorSubHeading": "Es ist ein unerwarteter Fehler aufgetreten. Bitte versuchen Sie es erneut. Wenn das Problem weiterhin besteht, kontaktieren Sie unser Support-Team.",
"anonymousUser": "Anonymer Benutzer",
"tryAgain": "Erneut versuchen",
"theme": "Design",
"lightTheme": "Hell",
"darkTheme": "Dunkel",
"systemTheme": "System",
"expandSidebar": "Seitenleiste einblenden",
"collapseSidebar": "Seitenleiste ausblenden",
"documentation": "Dokumentation",
"pricing": "Preise",
"getStarted": "Jetzt starten",
"getStartedWithPlan": "Mit {plan} starten",
"retry": "Wiederholen",
"contactUs": "Kontakt",
"loading": "Wird geladen. Bitte warten...",
"yourAccounts": "Ihre Konten",
"continueKey": "Weiter",
"skip": "Überspringen",
"info": "Info",
"signedInAs": "Angemeldet als",
"pageOfPages": "Seite {page} von {total}",
"showingRecordCount": "{pageSize} von {totalCount} Einträgen",
"noData": "Keine Daten vorhanden",
"pageNotFoundHeading": "404",
"errorPageHeading": "500",
"notifications": "Benachrichtigungen",
"noNotifications": "Keine Benachrichtigungen",
"justNow": "Gerade eben",
"newVersionAvailable": "Neue Version verfügbar",
"newVersionAvailableDescription": "Eine neue Version der Anwendung ist verfügbar. Bitte laden Sie die Seite neu, um die neuesten Aktualisierungen zu erhalten.",
"newVersionSubmitButton": "Neu laden und aktualisieren",
"back": "Zurück",
"routes": {
"home": "Startseite",
"account": "Konto",
"members": "Mitglieder",
"billing": "Abrechnung",
"dashboard": "Dashboard",
"settings": "Einstellungen",
"profile": "Profil",
"application": "Anwendung",
"modules": "Module",
"cmsMembers": "Mitglieder",
"courses": "Kurse",
"bookings": "Buchungen",
"finance": "Finanzen",
"documents": "Dokumente",
"newsletter": "Newsletter",
"events": "Veranstaltungen"
},
"roles": {
"owner": {
"label": "Inhaber"
},
"member": {
"label": "Mitglied"
}
},
"otp": {
"requestVerificationCode": "Bestätigungscode anfordern",
"requestVerificationCodeDescription": "Wir müssen Ihre Identität bestätigen. Wir senden einen Bestätigungscode an {email}.",
"sendingCode": "Code wird gesendet...",
"sendVerificationCode": "Bestätigungscode senden",
"enterVerificationCode": "Bestätigungscode eingeben",
"codeSentToEmail": "Wir haben einen Bestätigungscode an {email} gesendet.",
"verificationCode": "Bestätigungscode",
"enterCodeFromEmail": "Geben Sie den 6-stelligen Code ein, den wir Ihnen per E-Mail gesendet haben.",
"verifying": "Wird überprüft...",
"verifyCode": "Code überprüfen",
"requestNewCode": "Neuen Code anfordern",
"errorSendingCode": "Fehler beim Senden des Codes. Bitte versuchen Sie es erneut."
},
"cookieBanner": {
"title": "Wir verwenden Cookies 🍪",
"description": "Diese Website verwendet Cookies, um Ihnen die bestmögliche Erfahrung zu bieten.",
"reject": "Ablehnen",
"accept": "Akzeptieren"
},
"dropzone": {
"success": "{count} Datei(en) erfolgreich hochgeladen",
"error": "Fehler beim Hochladen von {count} Datei(en)",
"errorMessageUnknown": "Ein unbekannter Fehler ist aufgetreten.",
"errorMessageFileUnknown": "Unbekannte Datei",
"errorMessageFileSizeUnknown": "Unbekannte Dateigröße",
"errorMessageFileSizeTooSmall": "Datei ist zu klein",
"errorMessageFileSizeTooLarge": "Datei ist zu groß",
"uploading": "Wird hochgeladen...",
"uploadFiles": "{count} Datei(en) hochladen",
"maxFileSize": "Maximale Dateigröße: {size}",
"maxFiles": "Sie können maximal {count} Dateien hochladen. Bitte entfernen Sie {files} Dateien.",
"dragAndDrop": "Ziehen und ablegen oder",
"select": "Dateien auswählen",
"toUpload": "zum Hochladen"
}
}

View File

@@ -0,0 +1,46 @@
{
"blog": "Blog",
"blogSubtitle": "Neuigkeiten und Updates zur Plattform",
"changelog": "Änderungsprotokoll",
"changelogSubtitle": "Neueste Updates und Verbesserungen",
"noChangelogEntries": "Keine Einträge gefunden",
"changelogPaginationNext": "Nächste Seite",
"changelogPaginationPrevious": "Vorherige Seite",
"changelogNavigationPrevious": "Zurück",
"changelogNavigationNext": "Weiter",
"documentation": "Dokumentation",
"documentationSubtitle": "Anleitungen und Hilfe zum Einstieg",
"faq": "FAQ",
"faqSubtitle": "Häufig gestellte Fragen",
"pricing": "Preise",
"pricingSubtitle": "Tarife und Zahlungsoptionen",
"backToBlog": "Zurück zum Blog",
"noPosts": "Keine Beiträge gefunden",
"blogPaginationNext": "Nächste Seite",
"blogPaginationPrevious": "Vorherige Seite",
"readMore": "Weiterlesen",
"contactFaq": "Bei Fragen kontaktieren Sie uns bitte",
"contact": "Kontakt",
"about": "Über uns",
"product": "Produkt",
"legal": "Rechtliches",
"termsOfService": "Nutzungsbedingungen",
"termsOfServiceDescription": "Unsere Nutzungsbedingungen",
"cookiePolicy": "Cookie-Richtlinie",
"cookiePolicyDescription": "Unsere Cookie-Richtlinie",
"privacyPolicy": "Datenschutzerklärung",
"privacyPolicyDescription": "Unsere Datenschutzerklärung und Datennutzung",
"contactDescription": "Kontaktieren Sie uns bei Fragen oder Feedback",
"contactHeading": "Senden Sie uns eine Nachricht",
"contactSubheading": "Wir melden uns schnellstmöglich bei Ihnen",
"contactName": "Ihr Name",
"contactEmail": "Ihre E-Mail-Adresse",
"contactMessage": "Ihre Nachricht",
"sendMessage": "Nachricht senden",
"contactSuccess": "Ihre Nachricht wurde erfolgreich gesendet",
"contactError": "Fehler beim Senden Ihrer Nachricht",
"contactSuccessDescription": "Wir haben Ihre Nachricht erhalten und melden uns schnellstmöglich",
"contactErrorDescription": "Beim Senden ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut",
"footerDescription": "Hier können Sie eine Beschreibung Ihres Unternehmens oder Produkts einfügen",
"copyright": "© Copyright {year} {product}. Alle Rechte vorbehalten."
}

View File

@@ -0,0 +1,184 @@
{
"home": {
"pageTitle": "Startseite"
},
"settings": {
"pageTitle": "Einstellungen",
"pageDescription": "Teamdetails verwalten",
"teamLogo": "Team-Logo",
"teamLogoDescription": "Aktualisieren Sie das Logo Ihres Teams",
"teamName": "Teamname",
"teamNameDescription": "Aktualisieren Sie den Namen Ihres Teams",
"dangerZone": "Gefahrenbereich",
"dangerZoneDescription": "Dieser Bereich enthält unwiderrufliche Aktionen"
},
"members": {
"pageTitle": "Mitglieder"
},
"billing": {
"pageTitle": "Abrechnung"
},
"switchWorkspace": "Arbeitsbereich wechseln",
"yourTeams": "Ihre Teams ({teamsCount})",
"createTeam": "Team erstellen",
"creatingTeam": "Team wird erstellt...",
"personalAccount": "Persönliches Konto",
"searchAccount": "Konto suchen...",
"membersTabLabel": "Mitglieder",
"memberName": "Name",
"youLabel": "Sie",
"emailLabel": "E-Mail",
"roleLabel": "Rolle",
"primaryOwnerLabel": "Hauptinhaber",
"joinedAtLabel": "Beigetreten am",
"invitedAtLabel": "Eingeladen am",
"inviteMembersPageSubheading": "Mitglieder zu Ihrem Team einladen",
"createTeamModalHeading": "Team erstellen",
"createTeamModalDescription": "Erstellen Sie ein neues Team zur Verwaltung Ihrer Projekte und Mitglieder.",
"teamNameLabel": "Teamname",
"teamNameDescription": "Ihr Teamname sollte eindeutig und aussagekräftig sein",
"createTeamSubmitLabel": "Team erstellen",
"createFirstTeamHeading": "Erstellen Sie Ihr erstes Team",
"createFirstTeamDescription": "Erstellen Sie Ihr erstes Team und arbeiten Sie mit Ihren Teamkollegen zusammen.",
"getStarted": "Jetzt starten",
"createTeamSuccess": "Team erfolgreich erstellt",
"createTeamError": "Team konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"createTeamLoading": "Team wird erstellt...",
"settingsPageLabel": "Allgemein",
"createTeamDropdownLabel": "Neues Team",
"changeRole": "Rolle ändern",
"removeMember": "Aus Konto entfernen",
"inviteMembersSuccess": "Mitglieder erfolgreich eingeladen!",
"inviteMembersError": "Fehler aufgetreten! Bitte versuchen Sie es erneut",
"inviteMembersLoading": "Mitglieder werden eingeladen...",
"removeInviteButtonLabel": "Einladung entfernen",
"addAnotherMemberButtonLabel": "Weitere hinzufügen",
"inviteMembersButtonLabel": "Einladungen senden",
"removeMemberModalHeading": "Dieses Mitglied wird entfernt",
"removeMemberModalDescription": "Das Mitglied wird aus dem Team entfernt und hat keinen Zugriff mehr.",
"removeMemberSuccessMessage": "Mitglied erfolgreich entfernt",
"removeMemberErrorMessage": "Fehler aufgetreten. Bitte versuchen Sie es erneut",
"removeMemberErrorHeading": "Mitglied konnte nicht entfernt werden.",
"removeMemberLoadingMessage": "Mitglied wird entfernt...",
"removeMemberSubmitLabel": "Benutzer aus Team entfernen",
"chooseDifferentRoleError": "Rolle ist identisch mit der aktuellen",
"updateRole": "Rolle aktualisieren",
"updateRoleLoadingMessage": "Rolle wird aktualisiert...",
"updateRoleSuccessMessage": "Rolle erfolgreich aktualisiert",
"updatingRoleErrorMessage": "Fehler aufgetreten. Bitte versuchen Sie es erneut.",
"updateMemberRoleModalHeading": "Rolle des Mitglieds ändern",
"updateMemberRoleModalDescription": "Ändern Sie die Rolle des ausgewählten Mitglieds. Die Rolle bestimmt die Berechtigungen.",
"roleMustBeDifferent": "Die Rolle muss sich von der aktuellen unterscheiden",
"memberRoleInputLabel": "Mitgliederrolle",
"updateRoleDescription": "Wählen Sie eine Rolle für dieses Mitglied.",
"updateRoleSubmitLabel": "Rolle aktualisieren",
"transferOwnership": "Eigentum übertragen",
"transferOwnershipDescription": "Übertragen Sie das Eigentum des Teams an ein anderes Mitglied.",
"transferOwnershipInputLabel": "Geben Sie ÜBERTRAGEN zur Bestätigung ein.",
"transferOwnershipInputDescription": "Durch die Übertragung sind Sie nicht mehr der Hauptinhaber des Teams.",
"deleteInvitation": "Einladung löschen",
"deleteInvitationDialogDescription": "Sie löschen die Einladung. Der Benutzer kann dem Team nicht mehr beitreten.",
"deleteInviteSuccessMessage": "Einladung erfolgreich gelöscht",
"deleteInviteErrorMessage": "Einladung konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"deleteInviteLoadingMessage": "Einladung wird gelöscht...",
"confirmDeletingMemberInvite": "Sie löschen die Einladung an <b>{email}</b>",
"transferOwnershipDisclaimer": "Sie übertragen das Eigentum an <b>{member}</b>.",
"transferringOwnership": "Eigentum wird übertragen...",
"transferOwnershipSuccess": "Eigentum erfolgreich übertragen",
"transferOwnershipError": "Eigentum konnte nicht übertragen werden. Bitte versuchen Sie es erneut.",
"deleteInviteSubmitLabel": "Einladung löschen",
"youBadgeLabel": "Sie",
"updateTeamLoadingMessage": "Team wird aktualisiert...",
"updateTeamSuccessMessage": "Team erfolgreich aktualisiert",
"updateTeamErrorMessage": "Team konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut.",
"updateLogoErrorMessage": "Logo konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut.",
"teamNameInputLabel": "Teamname",
"teamLogoInputHeading": "Team-Logo hochladen",
"teamLogoInputSubheading": "Wählen Sie ein Bild als Team-Logo.",
"updateTeamSubmitLabel": "Team aktualisieren",
"inviteMembersHeading": "Mitglieder einladen",
"inviteMembersDescription": "Laden Sie Mitglieder per E-Mail und Rolle ein.",
"emailPlaceholder": "mitglied@email.de",
"membersPageHeading": "Mitglieder",
"inviteMembersButton": "Mitglieder einladen",
"invitingMembers": "Mitglieder werden eingeladen...",
"inviteMembersSuccessMessage": "Mitglieder erfolgreich eingeladen",
"inviteMembersErrorMessage": "Einladung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"pendingInvitesHeading": "Ausstehende Einladungen",
"pendingInvitesDescription": "Hier können Sie ausstehende Einladungen verwalten.",
"noPendingInvites": "Keine ausstehenden Einladungen",
"loadingMembers": "Mitglieder werden geladen...",
"loadMembersError": "Mitglieder konnten nicht geladen werden.",
"loadInvitedMembersError": "Eingeladene Mitglieder konnten nicht geladen werden.",
"loadingInvitedMembers": "Eingeladene Mitglieder werden geladen...",
"invitedBadge": "Eingeladen",
"duplicateInviteEmailError": "Diese E-Mail-Adresse wurde bereits eingegeben",
"invitingOwnAccountError": "Das ist Ihre eigene E-Mail!",
"dangerZone": "Gefahrenbereich",
"dangerZoneSubheading": "Team löschen oder verlassen",
"deleteTeam": "Team löschen",
"deleteTeamDescription": "Diese Aktion kann nicht rückgängig gemacht werden. Alle zugehörigen Daten werden gelöscht.",
"deletingTeam": "Team wird gelöscht",
"deleteTeamModalHeading": "Team löschen",
"deletingTeamDescription": "Sie löschen das Team {teamName}. Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteTeamInputField": "Geben Sie den Teamnamen zur Bestätigung ein",
"leaveTeam": "Team verlassen",
"leavingTeamModalHeading": "Team verlassen",
"leavingTeamModalDescription": "Sie verlassen dieses Team und haben keinen Zugriff mehr.",
"leaveTeamDescription": "Klicken Sie auf die Schaltfläche, um das Team zu verlassen. Sie müssen erneut eingeladen werden, um dem Team beizutreten.",
"deleteTeamDisclaimer": "Sie löschen das Team {teamName}. Diese Aktion kann nicht rückgängig gemacht werden.",
"leaveTeamDisclaimer": "Sie verlassen das Team {teamName}.",
"deleteTeamErrorHeading": "Team konnte nicht gelöscht werden.",
"leaveTeamErrorHeading": "Team konnte nicht verlassen werden.",
"searchMembersPlaceholder": "Mitglieder suchen",
"createTeamErrorHeading": "Team konnte nicht erstellt werden.",
"createTeamErrorMessage": "Fehler beim Erstellen des Teams. Bitte versuchen Sie es erneut.",
"transferTeamErrorHeading": "Eigentum konnte nicht übertragen werden.",
"transferTeamErrorMessage": "Fehler beim Übertragen des Eigentums. Bitte versuchen Sie es erneut.",
"updateRoleErrorHeading": "Rolle konnte nicht aktualisiert werden.",
"updateRoleErrorMessage": "Fehler beim Aktualisieren der Rolle. Bitte versuchen Sie es erneut.",
"searchInvitations": "Einladungen suchen",
"updateInvitation": "Einladung aktualisieren",
"removeInvitation": "Einladung entfernen",
"acceptInvitation": "Einladung annehmen",
"renewInvitation": "Einladung erneuern",
"resendInvitation": "Einladung erneut senden",
"expiresAtLabel": "Läuft ab am",
"expired": "Abgelaufen",
"active": "Aktiv",
"inviteStatus": "Status",
"inviteNotFoundOrExpired": "Einladung nicht gefunden oder abgelaufen",
"inviteNotFoundOrExpiredDescription": "Die Einladung ist entweder abgelaufen oder existiert nicht. Bitte kontaktieren Sie den Teaminhaber.",
"backToHome": "Zurück zur Startseite",
"renewInvitationDialogDescription": "Sie erneuern die Einladung an {email}.",
"renewInvitationErrorTitle": "Einladung konnte nicht erneuert werden.",
"renewInvitationErrorDescription": "Fehler bei der Erneuerung. Bitte versuchen Sie es erneut.",
"signInWithDifferentAccount": "Mit anderem Konto anmelden",
"signInWithDifferentAccountDescription": "Wenn Sie die Einladung mit einem anderen Konto annehmen möchten, melden Sie sich bitte ab und wieder an.",
"acceptInvitationHeading": "{accountName} beitreten",
"acceptInvitationDescription": "Klicken Sie auf die Schaltfläche, um {accountName} beizutreten",
"continueAs": "Weiter als {email}",
"joinTeamAccount": "Team beitreten",
"joiningTeam": "Team wird beigetreten...",
"leaveTeamInputLabel": "Geben Sie VERLASSEN zur Bestätigung ein.",
"leaveTeamInputDescription": "Durch das Verlassen haben Sie keinen Zugriff mehr auf das Team.",
"reservedNameError": "Dieser Name ist reserviert. Bitte wählen Sie einen anderen.",
"specialCharactersError": "Dieser Name darf keine Sonderzeichen enthalten.",
"teamSlugLabel": "Team-URL",
"teamSlugDescription": "Nur englische Buchstaben (a-z), Zahlen (0-9) und Bindestriche (-) sind erlaubt. Beispiel: mein-team",
"slugRequiredForNonLatinName": "Da Ihr Teamname nicht-englische Zeichen enthält, geben Sie bitte eine URL mit englischen Buchstaben an",
"invalidSlugError": "Nur englische Buchstaben (a-z), Zahlen (0-9) und Bindestriche (-) sind erlaubt",
"duplicateSlugError": "Diese URL ist bereits vergeben. Bitte wählen Sie eine andere.",
"checkingPolicies": "Wird geladen...",
"policyCheckError": "Einladungsbeschränkungen konnten nicht überprüft werden. Bitte versuchen Sie es erneut.",
"invitationsBlockedMultiple": "Einladungen sind aus folgenden Gründen nicht möglich:",
"invitationsBlockedDefault": "Einladungen sind aufgrund von Richtlinien derzeit nicht möglich.",
"policyErrors": {
"subscriptionRequired": "Für die Einladung von Teammitgliedern ist ein aktives Abonnement erforderlich.",
"paddleTrialRestriction": "Während der Testphase mit Pro-Kopf-Abrechnung auf Paddle können keine Mitglieder eingeladen werden."
},
"policyRemediation": {
"subscriptionRequired": "Bitte upgraden Sie Ihren Tarif",
"paddleTrialRestriction": "Warten Sie bis die Testphase endet oder upgraden Sie"
}
}

View File

@@ -0,0 +1,271 @@
{
"modules": {
"title": "Modules",
"description": "Manage your data modules",
"newModule": "New Module",
"editModule": "Edit Module",
"deleteModule": "Delete Module",
"moduleSettings": "Module Settings",
"moduleName": "Module Name",
"moduleDescription": "Description",
"moduleStatus": "Status",
"noModules": "No modules yet",
"createFirstModule": "Create your first module to start managing data.",
"fields": "Fields",
"records": "Records",
"import": "Import",
"export": "Export",
"print": "Print",
"copy": "Copy",
"lock": "Lock",
"unlock": "Unlock",
"bulkEdit": "Bulk Edit",
"search": "Search",
"filter": "Filter",
"advancedFilter": "Advanced Filter",
"clearFilters": "Clear Filters",
"noRecords": "No records found",
"newRecord": "New Record",
"editRecord": "Edit Record",
"deleteRecord": "Delete Record",
"confirmDelete": "Are you sure you want to delete this record?",
"recordSaved": "Record saved successfully",
"recordDeleted": "Record deleted successfully",
"recordLocked": "Record locked",
"recordUnlocked": "Record unlocked",
"validationError": "Please check your input",
"requiredField": "Required field",
"importTitle": "Import Data",
"importDescription": "Upload a CSV or Excel file",
"importMapping": "Column Mapping",
"importPreview": "Preview",
"importCommit": "Run Import",
"importSuccess": "{count} records imported successfully",
"importError": "Import failed",
"exportTitle": "Export Data",
"exportFormat": "Export Format",
"exportColumns": "Select Columns",
"exportAll": "Export All",
"exportSelected": "Export Selected",
"designer": "Module Designer",
"designerDescription": "Configure modules and fields"
},
"fieldTypes": {
"text": "Text",
"textarea": "Text Area",
"richtext": "Rich Text",
"checkbox": "Checkbox",
"radio": "Radio",
"hidden": "Hidden",
"select": "Select",
"password": "Password",
"file": "File",
"date": "Date",
"time": "Time",
"decimal": "Decimal",
"integer": "Integer",
"email": "Email",
"phone": "Phone",
"url": "URL",
"currency": "Currency",
"iban": "IBAN",
"color": "Color",
"computed": "Computed"
},
"accountSettings": {
"title": "Organization Settings",
"description": "Manage your organization settings",
"orgName": "Organization Name",
"orgAddress": "Address",
"orgPostalCode": "Postal Code",
"orgCity": "City",
"orgPhone": "Phone",
"orgEmail": "Email",
"orgWebsite": "Website",
"orgChairman": "Chairman",
"accountType": "Account Type",
"accountTypes": {
"verein": "Club/Association",
"vhs": "Adult Education Center",
"hotel": "Hotel",
"kommune": "Municipality",
"generic": "General"
},
"branding": "Branding",
"logoUrl": "Logo URL",
"primaryColor": "Primary Color",
"secondaryColor": "Secondary Color",
"sepaSettings": "SEPA Settings",
"creditorId": "Creditor ID",
"iban": "IBAN",
"bic": "BIC",
"emailSettings": "Email Settings",
"emailSenderName": "Sender Name",
"emailFooter": "Email Footer",
"saved": "Settings saved"
},
"members": {
"title": "Members",
"description": "Manage club members",
"newMember": "New Member",
"memberNumber": "Member Number",
"firstName": "First Name",
"lastName": "Last Name",
"dateOfBirth": "Date of Birth",
"address": "Address",
"postalCode": "Postal Code",
"city": "City",
"phone": "Phone",
"email": "Email",
"memberSince": "Member Since",
"status": "Status",
"dues": "Dues",
"applications": "Applications",
"cards": "Member Cards",
"statistics": "Statistics",
"sepaMandate": "SEPA Mandate",
"gdprConsent": "GDPR Consent"
},
"courses": {
"title": "Courses",
"description": "Manage course offerings",
"newCourse": "New Course",
"courseNumber": "Course Number",
"courseName": "Course Name",
"instructor": "Instructor",
"location": "Location",
"category": "Category",
"startDate": "Start Date",
"endDate": "End Date",
"sessions": "Sessions",
"participants": "Participants",
"capacity": "Capacity",
"waitlist": "Waitlist",
"fee": "Fee",
"calendar": "Calendar",
"attendance": "Attendance",
"enrollment": "Enrollment",
"enrollmentSuccess": "Enrollment successful",
"enrollmentCancelled": "Enrollment cancelled"
},
"bookings": {
"title": "Bookings",
"description": "Manage rooms and bookings",
"newBooking": "New Booking",
"rooms": "Rooms",
"guests": "Guests",
"checkIn": "Check-in",
"checkOut": "Check-out",
"roomType": "Room Type",
"availability": "Availability",
"price": "Price",
"extras": "Extras",
"calendar": "Availability Calendar"
},
"events": {
"title": "Events",
"description": "Manage events and holiday programs",
"newEvent": "New Event",
"registrations": "Registrations",
"holidayPasses": "Holiday Passes",
"eventDate": "Date",
"eventLocation": "Location",
"capacity": "Capacity"
},
"finance": {
"title": "Finance",
"description": "Manage SEPA collections and invoices",
"sepa": "SEPA Direct Debits",
"invoices": "Invoices",
"payments": "Payments",
"newBatch": "New Collection",
"newInvoice": "New Invoice",
"batchStatus": "Collection Status",
"executionDate": "Execution Date",
"totalAmount": "Total Amount",
"invoiceNumber": "Invoice Number",
"ibanValidation": "IBAN Validation"
},
"documents": {
"title": "Documents",
"description": "Create and manage documents",
"templates": "Templates",
"generate": "Generate",
"memberCards": "Member Cards",
"labels": "Labels",
"reports": "Reports"
},
"newsletter": {
"title": "Newsletter",
"description": "Create and send newsletters",
"newCampaign": "New Campaign",
"templates": "Templates",
"recipients": "Recipients",
"preview": "Preview",
"send": "Send",
"statistics": "Statistics",
"dispatching": "Sending..."
},
"gdpr": {
"title": "Data Protection (GDPR)",
"register": "Processing Register",
"purpose": "Purpose",
"legalBasis": "Legal Basis",
"dataCategories": "Data Categories",
"dataSubjects": "Data Subjects",
"retentionPeriod": "Retention Period",
"legalBases": {
"consent": "Consent",
"contract": "Contract",
"legal_obligation": "Legal Obligation",
"vital_interest": "Vital Interest",
"public_interest": "Public Interest",
"legitimate_interest": "Legitimate Interest"
}
},
"audit": {
"title": "Audit Log",
"description": "View change history",
"action": "Action",
"user": "User",
"table": "Table",
"timestamp": "Timestamp",
"oldData": "Before",
"newData": "After",
"actions": {
"insert": "Created",
"update": "Updated",
"delete": "Deleted",
"lock": "Locked"
}
},
"permissions": {
"modules.read": "Read Modules",
"modules.write": "Edit Modules",
"modules.delete": "Delete Modules",
"modules.insert": "Create Records",
"modules.lock": "Lock Records",
"modules.import": "Import Data",
"modules.export": "Export Data",
"modules.print": "Print",
"modules.manage": "Manage Modules",
"members.read": "Read Members",
"members.write": "Edit Members",
"courses.read": "Read Courses",
"courses.write": "Edit Courses",
"bookings.read": "Read Bookings",
"bookings.write": "Edit Bookings",
"finance.read": "Read Finance",
"finance.write": "Edit Finance",
"finance.sepa": "Execute SEPA Collections",
"documents.generate": "Generate Documents",
"newsletter.send": "Send Newsletter"
},
"status": {
"active": "Active",
"inactive": "Inactive",
"archived": "Archived",
"locked": "Locked",
"deleted": "Deleted"
}
}

View File

@@ -66,7 +66,15 @@
"dashboard": "Dashboard",
"settings": "Settings",
"profile": "Profile",
"application": "Application"
"application": "Application",
"modules": "Modules",
"cmsMembers": "Members",
"courses": "Courses",
"bookings": "Bookings",
"events": "Events",
"finance": "Finance",
"documents": "Documents",
"newsletter": "Newsletter"
},
"roles": {
"owner": {

View File

@@ -19,6 +19,7 @@ const namespaces = [
'teams',
'billing',
'marketing',
'cms',
] as const;
const isDevelopment = process.env.NODE_ENV === 'development';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
/**
* Resolve an account slug to its UUID.
* Used by every data loader in the CMS.
*/
export async function resolveAccountId(
client: SupabaseClient<Database>,
slug: string,
): Promise<string> {
const { data, error } = await client
.from('accounts')
.select('id')
.eq('slug', slug)
.single();
if (error || !data) {
throw new Error(`Account not found: ${slug}`);
}
return data.id;
}

View File

@@ -28,6 +28,7 @@
"supabase:db:dump:local": "supabase db dump --local --data-only"
},
"dependencies": {
"@base-ui/react": "catalog:",
"@hookform/resolvers": "catalog:",
"@kit/accounts": "workspace:*",
"@kit/admin": "workspace:*",
@@ -35,12 +36,20 @@
"@kit/auth": "workspace:*",
"@kit/billing": "workspace:*",
"@kit/billing-gateway": "workspace:*",
"@kit/booking-management": "workspace:*",
"@kit/cms": "workspace:*",
"@kit/course-management": "workspace:*",
"@kit/database-webhooks": "workspace:*",
"@kit/document-generator": "workspace:*",
"@kit/email-templates": "workspace:*",
"@kit/event-management": "workspace:*",
"@kit/finance": "workspace:*",
"@kit/i18n": "workspace:*",
"@kit/mailers": "workspace:*",
"@kit/member-management": "workspace:*",
"@kit/module-builder": "workspace:*",
"@kit/monitoring": "workspace:*",
"@kit/newsletter": "workspace:*",
"@kit/next": "workspace:*",
"@kit/notifications": "workspace:*",
"@kit/shared": "workspace:*",
@@ -54,6 +63,8 @@
"@supabase/supabase-js": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-table": "catalog:",
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"date-fns": "catalog:",
"lucide-react": "catalog:",
"next": "catalog:",
@@ -66,6 +77,7 @@
"react-dom": "catalog:",
"react-hook-form": "catalog:",
"recharts": "catalog:",
"shadcn": "^4.1.1",
"tailwind-merge": "catalog:",
"tw-animate-css": "catalog:",
"urlpattern-polyfill": "catalog:",

View File

@@ -1,20 +1,21 @@
/*
* global.css
*
* Global styles for the entire application
* globals.css — Main CSS entry point
* Theme: shadcn preset b1G3f4DGS (Nova + Inter)
*/
/* Tailwind CSS */
@import 'tailwindcss';
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
/* local styles - update the below if you add a new style */
@import './theme.css';
/* local styles */
@import './shadcn-ui.css';
@import './markdoc.css';
@import './makerkit.css';
/* content sources - update the below if you add a new path */
@custom-variant dark (&:is(.dark *));
/* content sources */
@source '../../../packages/*/src/**/*.{ts,tsx}';
@source '../../../packages/features/*/src/**/*.{ts,tsx}';
@source '../../../packages/billing/*/src/**/*.{ts,tsx}';
@@ -25,13 +26,10 @@
@layer base {
body {
@apply bg-background text-foreground;
font-feature-settings:
'rlig' 1,
'calt' 1;
font-feature-settings: 'rlig' 1, 'calt' 1;
}
html,
body {
html, body {
scroll-padding-top: 56px;
}
@@ -51,4 +49,125 @@
textarea::placeholder {
color: theme(--color-muted-foreground);
}
* {
@apply border-border outline-ring/50;
}
}
@utility container {
@apply mx-auto px-4 lg:px-8 xl:max-w-[80rem];
}
@theme inline {
--font-heading: var(--font-sans);
--font-serif: var(--font-sans);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
/* Theme: Preset b1G3f4DGS — teal primary, warm neutral base */
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.153 0.006 107.1);
--card: oklch(1 0 0);
--card-foreground: oklch(0.153 0.006 107.1);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.153 0.006 107.1);
--primary: oklch(0.52 0.105 223.128);
--primary-foreground: oklch(0.984 0.019 200.873);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.966 0.005 106.5);
--muted-foreground: oklch(0.58 0.031 107.3);
--accent: oklch(0.966 0.005 106.5);
--accent-foreground: oklch(0.228 0.013 107.4);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.93 0.007 106.5);
--input: oklch(0.93 0.007 106.5);
--ring: oklch(0.737 0.021 106.9);
--chart-1: oklch(0.879 0.169 91.605);
--chart-2: oklch(0.769 0.188 70.08);
--chart-3: oklch(0.666 0.179 58.318);
--chart-4: oklch(0.555 0.163 48.998);
--chart-5: oklch(0.473 0.137 46.201);
--radius: 0.625rem;
--sidebar: oklch(0.988 0.003 106.5);
--sidebar-foreground: oklch(0.153 0.006 107.1);
--sidebar-primary: oklch(0.609 0.126 221.723);
--sidebar-primary-foreground: oklch(0.984 0.019 200.873);
--sidebar-accent: oklch(0.966 0.005 106.5);
--sidebar-accent-foreground: oklch(0.228 0.013 107.4);
--sidebar-border: oklch(0.93 0.007 106.5);
--sidebar-ring: oklch(0.737 0.021 106.9);
}
.dark {
--background: oklch(0.153 0.006 107.1);
--foreground: oklch(0.988 0.003 106.5);
--card: oklch(0.228 0.013 107.4);
--card-foreground: oklch(0.988 0.003 106.5);
--popover: oklch(0.228 0.013 107.4);
--popover-foreground: oklch(0.988 0.003 106.5);
--primary: oklch(0.45 0.085 224.283);
--primary-foreground: oklch(0.984 0.019 200.873);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.286 0.016 107.4);
--muted-foreground: oklch(0.737 0.021 106.9);
--accent: oklch(0.286 0.016 107.4);
--accent-foreground: oklch(0.988 0.003 106.5);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.58 0.031 107.3);
--chart-1: oklch(0.879 0.169 91.605);
--chart-2: oklch(0.769 0.188 70.08);
--chart-3: oklch(0.666 0.179 58.318);
--chart-4: oklch(0.555 0.163 48.998);
--chart-5: oklch(0.473 0.137 46.201);
--sidebar: oklch(0.228 0.013 107.4);
--sidebar-foreground: oklch(0.988 0.003 106.5);
--sidebar-primary: oklch(0.715 0.143 215.221);
--sidebar-primary-foreground: oklch(0.302 0.056 229.695);
--sidebar-accent: oklch(0.286 0.016 107.4);
--sidebar-accent-foreground: oklch(0.988 0.003 106.5);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.58 0.031 107.3);
}

View File

@@ -1,92 +1 @@
/*
* theme.css
*
* Theme styles for the entire application
*/
@custom-variant dark (&:is(.dark *));
@utility container {
@apply mx-auto px-4 lg:px-8 xl:max-w-[80rem];
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/*
* Theme variables
*/
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.58 0.22 27);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.16 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.16 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.87 0 0);
--primary-foreground: oklch(0.16 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.16 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
/* theme.css — kept for backward compatibility, all theme tokens now in globals.css */

View File

@@ -0,0 +1,448 @@
/*
* -------------------------------------------------------
* CMS Foundation Schema
* Phase 1: Enums, permissions, account_settings, audit_log,
* GDPR register, file metadata, storage buckets.
* -------------------------------------------------------
*/
-- =====================================================
-- 1. New CMS Enums
-- =====================================================
create type public.cms_field_type as enum(
'text',
'textarea',
'richtext',
'checkbox',
'radio',
'hidden',
'select',
'password',
'file',
'date',
'time',
'decimal',
'integer',
'email',
'phone',
'url',
'currency',
'iban',
'color',
'computed'
);
create type public.cms_module_status as enum(
'active',
'inactive',
'archived'
);
create type public.cms_record_status as enum(
'active',
'locked',
'deleted',
'archived'
);
create type public.gdpr_legal_basis as enum(
'consent',
'contract',
'legal_obligation',
'vital_interest',
'public_interest',
'legitimate_interest'
);
create type public.cms_account_type as enum(
'verein',
'vhs',
'hotel',
'kommune',
'generic'
);
create type public.audit_action as enum(
'insert',
'update',
'delete',
'lock'
);
-- =====================================================
-- 2. Extend app_permissions with CMS values
-- =====================================================
-- ALTER TYPE … ADD VALUE cannot run inside a transaction in
-- older Postgres, but Supabase migrations run outside
-- explicit transactions by default.
alter type public.app_permissions add value if not exists 'modules.read';
alter type public.app_permissions add value if not exists 'modules.write';
alter type public.app_permissions add value if not exists 'modules.delete';
alter type public.app_permissions add value if not exists 'modules.insert';
alter type public.app_permissions add value if not exists 'modules.lock';
alter type public.app_permissions add value if not exists 'modules.import';
alter type public.app_permissions add value if not exists 'modules.export';
alter type public.app_permissions add value if not exists 'modules.print';
alter type public.app_permissions add value if not exists 'modules.manage';
alter type public.app_permissions add value if not exists 'members.read';
alter type public.app_permissions add value if not exists 'members.write';
alter type public.app_permissions add value if not exists 'courses.read';
alter type public.app_permissions add value if not exists 'courses.write';
alter type public.app_permissions add value if not exists 'bookings.read';
alter type public.app_permissions add value if not exists 'bookings.write';
alter type public.app_permissions add value if not exists 'finance.read';
alter type public.app_permissions add value if not exists 'finance.write';
alter type public.app_permissions add value if not exists 'finance.sepa';
alter type public.app_permissions add value if not exists 'documents.generate';
alter type public.app_permissions add value if not exists 'newsletter.send';
-- =====================================================
-- 3. account_settings — extends accounts per tenant
-- =====================================================
create table if not exists public.account_settings (
account_id uuid primary key references public.accounts(id) on delete cascade,
-- Organisation type
account_type public.cms_account_type not null default 'generic',
-- Org identity
org_name text,
org_address text,
org_postal_code text,
org_city text,
org_phone text,
org_email text,
org_website text,
org_chairman text,
-- Branding
logo_url text,
primary_color text default '#0f172a',
secondary_color text default '#3b82f6',
-- SEPA banking
creditor_id text, -- Gläubiger-ID
iban text,
bic text,
-- Email
email_sender_name text,
email_footer text,
-- Feature flags per tenant (jsonb for flexibility)
features jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
comment on table public.account_settings is 'CMS-specific settings per team account (organisation, branding, SEPA, features)';
-- RLS
alter table public.account_settings enable row level security;
-- Revoke + grant
revoke all on public.account_settings from authenticated, service_role;
grant select, insert, update on public.account_settings to authenticated;
grant all on public.account_settings to service_role;
-- SELECT: any member of the account
create policy account_settings_select
on public.account_settings for select
to authenticated
using (public.has_role_on_account(account_id));
-- INSERT: settings.manage permission
create policy account_settings_insert
on public.account_settings for insert
to authenticated
with check (
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
);
-- UPDATE: settings.manage permission
create policy account_settings_update
on public.account_settings for update
to authenticated
using (
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
);
-- Auto-update updated_at
create or replace function public.update_account_settings_timestamp()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
new.updated_at = now();
return new;
end;
$$;
create trigger trg_account_settings_updated_at
before update on public.account_settings
for each row
execute function public.update_account_settings_timestamp();
-- =====================================================
-- 4. audit_log — immutable action log
-- =====================================================
create table if not exists public.audit_log (
id bigint generated always as identity primary key,
account_id uuid not null references public.accounts(id) on delete cascade,
user_id uuid references auth.users(id) on delete set null,
table_name text not null,
record_id text not null,
action public.audit_action not null,
old_data jsonb,
new_data jsonb,
ip_address inet,
user_agent text,
created_at timestamptz not null default now()
);
comment on table public.audit_log is 'Immutable audit trail for CMS data changes';
-- Indexes
create index ix_audit_log_account on public.audit_log(account_id);
create index ix_audit_log_table_record on public.audit_log(table_name, record_id);
create index ix_audit_log_created on public.audit_log(created_at desc);
create index ix_audit_log_user on public.audit_log(user_id);
-- RLS
alter table public.audit_log enable row level security;
revoke all on public.audit_log from authenticated, service_role;
grant select, insert on public.audit_log to authenticated;
grant all on public.audit_log to service_role;
-- SELECT: members of the account
create policy audit_log_select
on public.audit_log for select
to authenticated
using (public.has_role_on_account(account_id));
-- INSERT: any authenticated member of the account
create policy audit_log_insert
on public.audit_log for insert
to authenticated
with check (public.has_role_on_account(account_id));
-- No UPDATE/DELETE — audit log is append-only
-- =====================================================
-- 5. gdpr_processing_register
-- =====================================================
create table if not exists public.gdpr_processing_register (
id bigint generated always as identity primary key,
account_id uuid not null references public.accounts(id) on delete cascade,
purpose text not null,
legal_basis public.gdpr_legal_basis not null,
data_categories text not null,
data_subjects text not null,
recipients text,
retention_period text not null,
technical_measures text,
responsible_person text,
notes text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
comment on table public.gdpr_processing_register is 'GDPR Art. 30 processing activity register per account';
-- Index
create index ix_gdpr_register_account on public.gdpr_processing_register(account_id);
-- RLS
alter table public.gdpr_processing_register enable row level security;
revoke all on public.gdpr_processing_register from authenticated, service_role;
grant select, insert, update, delete on public.gdpr_processing_register to authenticated;
grant all on public.gdpr_processing_register to service_role;
create policy gdpr_register_select
on public.gdpr_processing_register for select
to authenticated
using (public.has_role_on_account(account_id));
create policy gdpr_register_insert
on public.gdpr_processing_register for insert
to authenticated
with check (
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
);
create policy gdpr_register_update
on public.gdpr_processing_register for update
to authenticated
using (
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
);
create policy gdpr_register_delete
on public.gdpr_processing_register for delete
to authenticated
using (
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
);
-- Auto-update updated_at
create trigger trg_gdpr_register_updated_at
before update on public.gdpr_processing_register
for each row
execute function public.update_account_settings_timestamp();
-- =====================================================
-- 6. cms_files — file metadata with Storage paths
-- =====================================================
create table if not exists public.cms_files (
id bigint generated always as identity primary key,
account_id uuid not null references public.accounts(id) on delete cascade,
module_name text not null,
record_id text not null,
field_name text not null,
file_name text not null,
original_name text not null,
mime_type text not null,
file_size bigint not null default 0,
storage_path text not null,
created_at timestamptz not null default now(),
created_by uuid references auth.users(id) on delete set null
);
comment on table public.cms_files is 'File metadata linking CMS records to Supabase Storage objects';
-- Indexes
create index ix_cms_files_account on public.cms_files(account_id);
create index ix_cms_files_record on public.cms_files(module_name, record_id);
create index ix_cms_files_field on public.cms_files(module_name, record_id, field_name);
-- RLS
alter table public.cms_files enable row level security;
revoke all on public.cms_files from authenticated, service_role;
grant select, insert, update, delete on public.cms_files to authenticated;
grant all on public.cms_files to service_role;
create policy cms_files_select
on public.cms_files for select
to authenticated
using (public.has_role_on_account(account_id));
create policy cms_files_insert
on public.cms_files for insert
to authenticated
with check (public.has_role_on_account(account_id));
create policy cms_files_update
on public.cms_files for update
to authenticated
using (public.has_role_on_account(account_id));
create policy cms_files_delete
on public.cms_files for delete
to authenticated
using (public.has_role_on_account(account_id));
-- =====================================================
-- 7. Storage Buckets
-- =====================================================
-- CMS uploads (module file fields)
insert into storage.buckets (id, name, public)
values ('cms-uploads', 'cms-uploads', false)
on conflict (id) do nothing;
-- CMS documents (generated PDFs, invoices)
insert into storage.buckets (id, name, public)
values ('cms-documents', 'cms-documents', false)
on conflict (id) do nothing;
-- CMS exports (CSV, Excel exports)
insert into storage.buckets (id, name, public)
values ('cms-exports', 'cms-exports', false)
on conflict (id) do nothing;
-- Storage RLS: account members can access their own files
-- Uses the first path segment as account_id
create or replace function kit.get_storage_path_account_id(name text)
returns uuid
language plpgsql
security definer
set search_path = ''
as $$
begin
-- Path format: {account_id}/...
return split_part(name, '/', 1)::uuid;
exception when others then
return null;
end;
$$;
grant execute on function kit.get_storage_path_account_id(text)
to authenticated, service_role;
-- RLS for cms-uploads bucket
create policy cms_uploads_select
on storage.objects for select
to authenticated
using (
bucket_id = 'cms-uploads'
and public.has_role_on_account(kit.get_storage_path_account_id(name))
);
create policy cms_uploads_insert
on storage.objects for insert
to authenticated
with check (
bucket_id = 'cms-uploads'
and public.has_role_on_account(kit.get_storage_path_account_id(name))
);
create policy cms_uploads_delete
on storage.objects for delete
to authenticated
using (
bucket_id = 'cms-uploads'
and public.has_role_on_account(kit.get_storage_path_account_id(name))
);
-- RLS for cms-documents bucket
create policy cms_documents_select
on storage.objects for select
to authenticated
using (
bucket_id = 'cms-documents'
and public.has_role_on_account(kit.get_storage_path_account_id(name))
);
create policy cms_documents_insert
on storage.objects for insert
to authenticated
with check (
bucket_id = 'cms-documents'
and public.has_role_on_account(kit.get_storage_path_account_id(name))
);
-- RLS for cms-exports bucket
create policy cms_exports_select
on storage.objects for select
to authenticated
using (
bucket_id = 'cms-exports'
and public.has_role_on_account(kit.get_storage_path_account_id(name))
);
create policy cms_exports_insert
on storage.objects for insert
to authenticated
with check (
bucket_id = 'cms-exports'
and public.has_role_on_account(kit.get_storage_path_account_id(name))
);

View File

@@ -0,0 +1,40 @@
-- =====================================================
-- 8. Seed CMS permissions for existing roles
-- =====================================================
-- Owner gets ALL CMS permissions
insert into public.role_permissions (role, permission) values
('owner', 'modules.read'),
('owner', 'modules.write'),
('owner', 'modules.delete'),
('owner', 'modules.insert'),
('owner', 'modules.lock'),
('owner', 'modules.import'),
('owner', 'modules.export'),
('owner', 'modules.print'),
('owner', 'modules.manage'),
('owner', 'members.read'),
('owner', 'members.write'),
('owner', 'courses.read'),
('owner', 'courses.write'),
('owner', 'bookings.read'),
('owner', 'bookings.write'),
('owner', 'finance.read'),
('owner', 'finance.write'),
('owner', 'finance.sepa'),
('owner', 'documents.generate'),
('owner', 'newsletter.send')
on conflict (role, permission) do nothing;
-- Member gets read + basic write permissions
insert into public.role_permissions (role, permission) values
('member', 'modules.read'),
('member', 'modules.write'),
('member', 'modules.insert'),
('member', 'modules.export'),
('member', 'modules.print'),
('member', 'members.read'),
('member', 'courses.read'),
('member', 'bookings.read'),
('member', 'finance.read')
on conflict (role, permission) do nothing;

View File

@@ -0,0 +1,482 @@
/*
* -------------------------------------------------------
* Module Engine Schema
* Phase 2: modules, module_fields, module_permissions,
* module_relations, module_query() RPC
* -------------------------------------------------------
*/
-- =====================================================
-- 1. modules — dynamic module definitions
-- =====================================================
create table if not exists public.modules (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
name text not null,
display_name text not null,
description text,
icon text default 'table',
table_name text,
primary_key_column text default 'id',
status public.cms_module_status not null default 'active',
sort_order integer not null default 0,
-- Defaults
default_sort_field text,
default_sort_direction text default 'asc' check (default_sort_direction in ('asc', 'desc')),
default_page_size integer default 25,
-- Feature toggles
enable_search boolean not null default true,
enable_filter boolean not null default true,
enable_export boolean not null default true,
enable_import boolean not null default false,
enable_print boolean not null default true,
enable_copy boolean not null default false,
enable_bulk_edit boolean not null default false,
enable_history boolean not null default true,
enable_soft_delete boolean not null default true,
enable_lock boolean not null default false,
-- Extensibility
hooks jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique(account_id, name)
);
comment on table public.modules is 'Dynamic module definitions — replaces legacy m_module';
-- Indexes
create index ix_modules_account on public.modules(account_id);
create index ix_modules_status on public.modules(account_id, status);
-- RLS
alter table public.modules enable row level security;
revoke all on public.modules from authenticated, service_role;
grant select, insert, update, delete on public.modules to authenticated;
grant all on public.modules to service_role;
create policy modules_select on public.modules for select to authenticated
using (public.has_role_on_account(account_id));
create policy modules_insert on public.modules for insert to authenticated
with check (
public.has_permission(auth.uid(), account_id, 'modules.manage'::public.app_permissions)
);
create policy modules_update on public.modules for update to authenticated
using (
public.has_permission(auth.uid(), account_id, 'modules.manage'::public.app_permissions)
);
create policy modules_delete on public.modules for delete to authenticated
using (
public.has_permission(auth.uid(), account_id, 'modules.manage'::public.app_permissions)
);
-- Auto-update timestamp
create trigger trg_modules_updated_at
before update on public.modules
for each row execute function public.update_account_settings_timestamp();
-- =====================================================
-- 2. module_fields — field definitions per module
-- =====================================================
create table if not exists public.module_fields (
id uuid primary key default gen_random_uuid(),
module_id uuid not null references public.modules(id) on delete cascade,
name text not null,
display_name text not null,
field_type public.cms_field_type not null default 'text',
sql_type text default 'text',
-- Constraints
is_required boolean not null default false,
is_unique boolean not null default false,
default_value text,
min_value numeric,
max_value numeric,
max_length integer,
regex_pattern text,
-- Layout
sort_order integer not null default 0,
width text default 'full',
section text default 'default',
row_index integer default 0,
col_index integer default 0,
placeholder text,
help_text text,
-- Visibility
show_in_table boolean not null default true,
show_in_form boolean not null default true,
show_in_search boolean not null default false,
show_in_filter boolean not null default false,
show_in_export boolean not null default true,
show_in_print boolean not null default true,
-- Behavior
is_sortable boolean not null default true,
is_readonly boolean not null default false,
is_encrypted boolean not null default false,
is_copyable boolean not null default true,
validation_fn text,
-- Lookup (foreign key to another module)
lookup_module_id uuid references public.modules(id) on delete set null,
lookup_display_field text,
lookup_value_field text,
-- Select options (for select/radio/checkbox fields)
select_options jsonb,
-- GDPR
is_personal_data boolean not null default false,
gdpr_purpose text,
-- File upload config
allowed_mime_types text[],
max_file_size bigint,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique(module_id, name)
);
comment on table public.module_fields is 'Field definitions per module — replaces legacy m_modulfeld';
-- Indexes
create index ix_module_fields_module on public.module_fields(module_id);
create index ix_module_fields_sort on public.module_fields(module_id, sort_order);
create index ix_module_fields_lookup on public.module_fields(lookup_module_id);
-- RLS — inherits from parent module's account
alter table public.module_fields enable row level security;
revoke all on public.module_fields from authenticated, service_role;
grant select, insert, update, delete on public.module_fields to authenticated;
grant all on public.module_fields to service_role;
create policy module_fields_select on public.module_fields for select to authenticated
using (
exists (
select 1 from public.modules m
where m.id = module_fields.module_id
and public.has_role_on_account(m.account_id)
)
);
create policy module_fields_insert on public.module_fields for insert to authenticated
with check (
exists (
select 1 from public.modules m
where m.id = module_fields.module_id
and public.has_permission(auth.uid(), m.account_id, 'modules.manage'::public.app_permissions)
)
);
create policy module_fields_update on public.module_fields for update to authenticated
using (
exists (
select 1 from public.modules m
where m.id = module_fields.module_id
and public.has_permission(auth.uid(), m.account_id, 'modules.manage'::public.app_permissions)
)
);
create policy module_fields_delete on public.module_fields for delete to authenticated
using (
exists (
select 1 from public.modules m
where m.id = module_fields.module_id
and public.has_permission(auth.uid(), m.account_id, 'modules.manage'::public.app_permissions)
)
);
create trigger trg_module_fields_updated_at
before update on public.module_fields
for each row execute function public.update_account_settings_timestamp();
-- =====================================================
-- 3. module_permissions — per-module role permissions
-- =====================================================
create table if not exists public.module_permissions (
id uuid primary key default gen_random_uuid(),
module_id uuid not null references public.modules(id) on delete cascade,
role varchar(50) not null references public.roles(name),
can_read boolean not null default true,
can_insert boolean not null default false,
can_update boolean not null default false,
can_delete boolean not null default false,
can_lock boolean not null default false,
can_import boolean not null default false,
can_export boolean not null default true,
can_print boolean not null default true,
can_bulk_edit boolean not null default false,
can_manage boolean not null default false,
unique(module_id, role)
);
comment on table public.module_permissions is 'Per-module, per-role permission overrides';
create index ix_module_permissions_module on public.module_permissions(module_id);
alter table public.module_permissions enable row level security;
revoke all on public.module_permissions from authenticated, service_role;
grant select, insert, update, delete on public.module_permissions to authenticated;
grant all on public.module_permissions to service_role;
create policy module_permissions_select on public.module_permissions for select to authenticated
using (
exists (
select 1 from public.modules m
where m.id = module_permissions.module_id
and public.has_role_on_account(m.account_id)
)
);
create policy module_permissions_mutate on public.module_permissions for all to authenticated
using (
exists (
select 1 from public.modules m
where m.id = module_permissions.module_id
and public.has_permission(auth.uid(), m.account_id, 'modules.manage'::public.app_permissions)
)
);
-- =====================================================
-- 4. module_relations — inter-module relationships
-- =====================================================
create table if not exists public.module_relations (
id uuid primary key default gen_random_uuid(),
source_module_id uuid not null references public.modules(id) on delete cascade,
source_field_id uuid not null references public.module_fields(id) on delete cascade,
target_module_id uuid not null references public.modules(id) on delete cascade,
target_field_id uuid references public.module_fields(id) on delete set null,
relation_type text not null check (relation_type in ('lookup', 'parent', 'child', 'many_to_many')),
created_at timestamptz not null default now()
);
comment on table public.module_relations is 'Relationships between modules (lookup, parent-child, M:N)';
create index ix_module_relations_source on public.module_relations(source_module_id);
create index ix_module_relations_target on public.module_relations(target_module_id);
alter table public.module_relations enable row level security;
revoke all on public.module_relations from authenticated, service_role;
grant select, insert, update, delete on public.module_relations to authenticated;
grant all on public.module_relations to service_role;
create policy module_relations_select on public.module_relations for select to authenticated
using (
exists (
select 1 from public.modules m
where m.id = module_relations.source_module_id
and public.has_role_on_account(m.account_id)
)
);
create policy module_relations_mutate on public.module_relations for all to authenticated
using (
exists (
select 1 from public.modules m
where m.id = module_relations.source_module_id
and public.has_permission(auth.uid(), m.account_id, 'modules.manage'::public.app_permissions)
)
);
-- =====================================================
-- 5. module_records — generic record storage
-- =====================================================
create table if not exists public.module_records (
id uuid primary key default gen_random_uuid(),
module_id uuid not null references public.modules(id) on delete cascade,
account_id uuid not null references public.accounts(id) on delete cascade,
data jsonb not null default '{}'::jsonb,
status public.cms_record_status not null default 'active',
locked_by uuid references auth.users(id) on delete set null,
locked_at timestamptz,
created_by uuid references auth.users(id) on delete set null,
updated_by uuid references auth.users(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
comment on table public.module_records is 'Generic record storage for all dynamic modules';
create index ix_module_records_module on public.module_records(module_id);
create index ix_module_records_account on public.module_records(account_id);
create index ix_module_records_status on public.module_records(module_id, status);
create index ix_module_records_data on public.module_records using gin(data);
create index ix_module_records_created on public.module_records(created_at desc);
alter table public.module_records enable row level security;
revoke all on public.module_records from authenticated, service_role;
grant select, insert, update, delete on public.module_records to authenticated;
grant all on public.module_records to service_role;
create policy module_records_select on public.module_records for select to authenticated
using (public.has_role_on_account(account_id));
create policy module_records_insert on public.module_records for insert to authenticated
with check (
public.has_permission(auth.uid(), account_id, 'modules.insert'::public.app_permissions)
);
create policy module_records_update on public.module_records for update to authenticated
using (
public.has_permission(auth.uid(), account_id, 'modules.write'::public.app_permissions)
);
create policy module_records_delete on public.module_records for delete to authenticated
using (
public.has_permission(auth.uid(), account_id, 'modules.delete'::public.app_permissions)
);
create trigger trg_module_records_updated_at
before update on public.module_records
for each row execute function public.update_account_settings_timestamp();
-- =====================================================
-- 6. module_query() — dynamic query RPC
-- =====================================================
create or replace function public.module_query(
p_module_id uuid,
p_filters jsonb default '[]'::jsonb,
p_sort_field text default null,
p_sort_direction text default 'asc',
p_page integer default 1,
p_page_size integer default 25,
p_search text default null
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_account_id uuid;
v_total bigint;
v_offset integer;
v_records jsonb;
v_query text;
v_where text := '';
v_filter jsonb;
v_field text;
v_operator text;
v_value text;
begin
-- Get the module's account_id and verify access
select m.account_id into v_account_id
from public.modules m
where m.id = p_module_id and m.status = 'active';
if v_account_id is null then
raise exception 'Module not found or inactive';
end if;
if not public.has_role_on_account(v_account_id) then
raise exception 'Access denied';
end if;
-- Build filter WHERE clause from JSON filters
-- Each filter: {"field": "name", "operator": "eq", "value": "test"}
if p_filters is not null and jsonb_array_length(p_filters) > 0 then
for v_filter in select * from jsonb_array_elements(p_filters)
loop
v_field := v_filter->>'field';
v_operator := v_filter->>'operator';
v_value := v_filter->>'value';
-- Sanitize field name (only alphanumeric + underscore)
if v_field !~ '^[a-zA-Z_][a-zA-Z0-9_]*$' then
continue;
end if;
case v_operator
when 'eq' then
v_where := v_where || format(' AND data->>%L = %L', v_field, v_value);
when 'neq' then
v_where := v_where || format(' AND data->>%L != %L', v_field, v_value);
when 'gt' then
v_where := v_where || format(' AND (data->>%L)::numeric > %L::numeric', v_field, v_value);
when 'gte' then
v_where := v_where || format(' AND (data->>%L)::numeric >= %L::numeric', v_field, v_value);
when 'lt' then
v_where := v_where || format(' AND (data->>%L)::numeric < %L::numeric', v_field, v_value);
when 'lte' then
v_where := v_where || format(' AND (data->>%L)::numeric <= %L::numeric', v_field, v_value);
when 'like' then
v_where := v_where || format(' AND data->>%L ILIKE %L', v_field, '%' || v_value || '%');
when 'is_null' then
v_where := v_where || format(' AND (data->>%L IS NULL OR data->>%L = '''')', v_field, v_field);
when 'not_null' then
v_where := v_where || format(' AND data->>%L IS NOT NULL AND data->>%L != ''''', v_field, v_field);
else
null; -- skip unknown operators
end case;
end loop;
end if;
-- Text search across all data fields
if p_search is not null and p_search != '' then
v_where := v_where || format(' AND data::text ILIKE %L', '%' || p_search || '%');
end if;
-- Count total
execute format(
'SELECT count(*) FROM public.module_records WHERE module_id = %L AND status != ''deleted'' %s',
p_module_id, v_where
) into v_total;
-- Calculate offset
v_offset := (p_page - 1) * p_page_size;
-- Build sort
if p_sort_field is not null and p_sort_field ~ '^[a-zA-Z_][a-zA-Z0-9_]*$' then
v_query := format(
'SELECT jsonb_agg(row_to_json(r)) FROM (
SELECT id, data, status, locked_by, locked_at, created_by, updated_by, created_at, updated_at
FROM public.module_records
WHERE module_id = %L AND status != ''deleted'' %s
ORDER BY data->>%L %s NULLS LAST
LIMIT %L OFFSET %L
) r',
p_module_id, v_where, p_sort_field,
case when p_sort_direction = 'desc' then 'DESC' else 'ASC' end,
p_page_size, v_offset
);
else
v_query := format(
'SELECT jsonb_agg(row_to_json(r)) FROM (
SELECT id, data, status, locked_by, locked_at, created_by, updated_by, created_at, updated_at
FROM public.module_records
WHERE module_id = %L AND status != ''deleted'' %s
ORDER BY created_at DESC
LIMIT %L OFFSET %L
) r',
p_module_id, v_where, p_page_size, v_offset
);
end if;
execute v_query into v_records;
return jsonb_build_object(
'data', coalesce(v_records, '[]'::jsonb),
'pagination', jsonb_build_object(
'page', p_page,
'pageSize', p_page_size,
'total', v_total,
'totalPages', ceil(v_total::numeric / p_page_size::numeric)::integer
)
);
end;
$$;
grant execute on function public.module_query(uuid, jsonb, text, text, integer, integer, text)
to authenticated, service_role;

View File

@@ -0,0 +1,211 @@
/*
* -------------------------------------------------------
* Member Management Schema
* Phase 4: members, membership_applications, dues_categories, member_cards
* -------------------------------------------------------
*/
-- =====================================================
-- 1. Enums
-- =====================================================
create type public.membership_status as enum(
'active', 'inactive', 'pending', 'resigned', 'excluded', 'deceased'
);
create type public.sepa_mandate_status as enum(
'active', 'pending', 'revoked', 'expired'
);
create type public.application_status as enum(
'submitted', 'review', 'approved', 'rejected'
);
-- =====================================================
-- 2. members
-- =====================================================
create table if not exists public.members (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
member_number text,
-- Personal
first_name text not null,
last_name text not null,
date_of_birth date,
gender text check (gender in ('male', 'female', 'diverse', null)),
title text, -- Dr., Prof., etc.
-- Contact
email text,
phone text,
mobile text,
-- Address
street text,
house_number text,
postal_code text,
city text,
country text default 'DE',
-- Membership
status public.membership_status not null default 'active',
entry_date date not null default current_date,
exit_date date,
exit_reason text,
dues_category_id uuid,
-- SEPA
sepa_mandate_id text,
sepa_mandate_date date,
sepa_mandate_status public.sepa_mandate_status default 'pending',
iban text,
bic text,
account_holder text,
-- GDPR
gdpr_consent boolean not null default false,
gdpr_consent_date timestamptz,
gdpr_data_source text,
-- Meta
notes text,
custom_data jsonb not null default '{}'::jsonb,
created_by uuid references auth.users(id) on delete set null,
updated_by uuid references auth.users(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique(account_id, member_number)
);
comment on table public.members is 'Club/association members — replaces legacy ve_mitglieder';
create index ix_members_account on public.members(account_id);
create index ix_members_status on public.members(account_id, status);
create index ix_members_name on public.members(account_id, last_name, first_name);
create index ix_members_email on public.members(account_id, email);
alter table public.members enable row level security;
revoke all on public.members from authenticated, service_role;
grant select, insert, update, delete on public.members to authenticated;
grant all on public.members to service_role;
create policy members_select on public.members for select to authenticated
using (public.has_role_on_account(account_id));
create policy members_insert on public.members for insert to authenticated
with check (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
create policy members_update on public.members for update to authenticated
using (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
create policy members_delete on public.members for delete to authenticated
using (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
create trigger trg_members_updated_at
before update on public.members
for each row execute function public.update_account_settings_timestamp();
-- =====================================================
-- 3. dues_categories — tiered pricing
-- =====================================================
create table if not exists public.dues_categories (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
name text not null,
description text,
amount numeric(10,2) not null default 0,
interval text not null default 'yearly' check (interval in ('monthly', 'quarterly', 'half_yearly', 'yearly')),
is_default boolean not null default false,
sort_order integer not null default 0,
created_at timestamptz not null default now()
);
comment on table public.dues_categories is 'Membership dues/fee categories';
create index ix_dues_categories_account on public.dues_categories(account_id);
alter table public.dues_categories enable row level security;
revoke all on public.dues_categories from authenticated, service_role;
grant select, insert, update, delete on public.dues_categories to authenticated;
grant all on public.dues_categories to service_role;
create policy dues_categories_select on public.dues_categories for select to authenticated
using (public.has_role_on_account(account_id));
create policy dues_categories_mutate on public.dues_categories for all to authenticated
using (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
-- Add FK from members to dues_categories
alter table public.members
add constraint fk_members_dues_category
foreign key (dues_category_id) references public.dues_categories(id) on delete set null;
-- =====================================================
-- 4. membership_applications — workflow
-- =====================================================
create table if not exists public.membership_applications (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
first_name text not null,
last_name text not null,
email text,
phone text,
street text,
postal_code text,
city text,
date_of_birth date,
message text,
status public.application_status not null default 'submitted',
reviewed_by uuid references auth.users(id) on delete set null,
reviewed_at timestamptz,
review_notes text,
member_id uuid references public.members(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
comment on table public.membership_applications is 'Online membership applications with approval workflow';
create index ix_applications_account on public.membership_applications(account_id);
create index ix_applications_status on public.membership_applications(account_id, status);
alter table public.membership_applications enable row level security;
revoke all on public.membership_applications from authenticated, service_role;
grant select, insert, update, delete on public.membership_applications to authenticated;
grant all on public.membership_applications to service_role;
create policy applications_select on public.membership_applications for select to authenticated
using (public.has_role_on_account(account_id));
create policy applications_mutate on public.membership_applications for all to authenticated
using (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
-- =====================================================
-- 5. member_cards — ID cards
-- =====================================================
create table if not exists public.member_cards (
id uuid primary key default gen_random_uuid(),
member_id uuid not null references public.members(id) on delete cascade,
account_id uuid not null references public.accounts(id) on delete cascade,
card_number text,
valid_from date not null default current_date,
valid_until date,
pdf_storage_path text,
created_at timestamptz not null default now()
);
comment on table public.member_cards is 'Member ID cards with generated PDFs';
create index ix_member_cards_member on public.member_cards(member_id);
alter table public.member_cards enable row level security;
revoke all on public.member_cards from authenticated, service_role;
grant select, insert, update, delete on public.member_cards to authenticated;
grant all on public.member_cards to service_role;
create policy member_cards_select on public.member_cards for select to authenticated
using (public.has_role_on_account(account_id));
create policy member_cards_mutate on public.member_cards for all to authenticated
using (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));

View File

@@ -0,0 +1,168 @@
/*
* -------------------------------------------------------
* Course Management Schema
* Phase 5: courses, sessions, categories, participants,
* instructors, locations, attendance
* -------------------------------------------------------
*/
create type public.enrollment_status as enum(
'enrolled', 'waitlisted', 'cancelled', 'completed'
);
-- Course categories (hierarchical)
create table if not exists public.course_categories (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
parent_id uuid references public.course_categories(id) on delete set null,
name text not null,
description text,
sort_order integer not null default 0,
created_at timestamptz not null default now()
);
create index ix_course_categories_account on public.course_categories(account_id);
alter table public.course_categories enable row level security;
revoke all on public.course_categories from authenticated, service_role;
grant select, insert, update, delete on public.course_categories to authenticated;
grant all on public.course_categories to service_role;
create policy course_categories_select on public.course_categories for select to authenticated using (public.has_role_on_account(account_id));
create policy course_categories_mutate on public.course_categories for all to authenticated using (public.has_permission(auth.uid(), account_id, 'courses.write'::public.app_permissions));
-- Locations
create table if not exists public.course_locations (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
name text not null,
address text,
room text,
capacity integer,
notes text,
created_at timestamptz not null default now()
);
create index ix_course_locations_account on public.course_locations(account_id);
alter table public.course_locations enable row level security;
revoke all on public.course_locations from authenticated, service_role;
grant select, insert, update, delete on public.course_locations to authenticated;
grant all on public.course_locations to service_role;
create policy course_locations_select on public.course_locations for select to authenticated using (public.has_role_on_account(account_id));
create policy course_locations_mutate on public.course_locations for all to authenticated using (public.has_permission(auth.uid(), account_id, 'courses.write'::public.app_permissions));
-- Instructors
create table if not exists public.course_instructors (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
first_name text not null,
last_name text not null,
email text,
phone text,
qualifications text,
hourly_rate numeric(10,2),
notes text,
created_at timestamptz not null default now()
);
create index ix_course_instructors_account on public.course_instructors(account_id);
alter table public.course_instructors enable row level security;
revoke all on public.course_instructors from authenticated, service_role;
grant select, insert, update, delete on public.course_instructors to authenticated;
grant all on public.course_instructors to service_role;
create policy course_instructors_select on public.course_instructors for select to authenticated using (public.has_role_on_account(account_id));
create policy course_instructors_mutate on public.course_instructors for all to authenticated using (public.has_permission(auth.uid(), account_id, 'courses.write'::public.app_permissions));
-- Courses
create table if not exists public.courses (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
course_number text,
name text not null,
description text,
category_id uuid references public.course_categories(id) on delete set null,
instructor_id uuid references public.course_instructors(id) on delete set null,
location_id uuid references public.course_locations(id) on delete set null,
start_date date,
end_date date,
fee numeric(10,2) not null default 0,
reduced_fee numeric(10,2),
capacity integer not null default 20,
min_participants integer default 5,
status text not null default 'planned' check (status in ('planned', 'open', 'running', 'completed', 'cancelled')),
registration_deadline date,
notes text,
custom_data jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index ix_courses_account on public.courses(account_id);
create index ix_courses_status on public.courses(account_id, status);
create index ix_courses_dates on public.courses(account_id, start_date, end_date);
alter table public.courses enable row level security;
revoke all on public.courses from authenticated, service_role;
grant select, insert, update, delete on public.courses to authenticated;
grant all on public.courses to service_role;
create policy courses_select on public.courses for select to authenticated using (public.has_role_on_account(account_id));
create policy courses_insert on public.courses for insert to authenticated with check (public.has_permission(auth.uid(), account_id, 'courses.write'::public.app_permissions));
create policy courses_update on public.courses for update to authenticated using (public.has_permission(auth.uid(), account_id, 'courses.write'::public.app_permissions));
create policy courses_delete on public.courses for delete to authenticated using (public.has_permission(auth.uid(), account_id, 'courses.write'::public.app_permissions));
create trigger trg_courses_updated_at before update on public.courses for each row execute function public.update_account_settings_timestamp();
-- Course sessions (individual dates/times)
create table if not exists public.course_sessions (
id uuid primary key default gen_random_uuid(),
course_id uuid not null references public.courses(id) on delete cascade,
session_date date not null,
start_time time not null,
end_time time not null,
location_id uuid references public.course_locations(id) on delete set null,
notes text,
is_cancelled boolean not null default false,
created_at timestamptz not null default now()
);
create index ix_course_sessions_course on public.course_sessions(course_id);
create index ix_course_sessions_date on public.course_sessions(session_date);
alter table public.course_sessions enable row level security;
revoke all on public.course_sessions from authenticated, service_role;
grant select, insert, update, delete on public.course_sessions to authenticated;
grant all on public.course_sessions to service_role;
create policy course_sessions_select on public.course_sessions for select to authenticated using (exists (select 1 from public.courses c where c.id = course_sessions.course_id and public.has_role_on_account(c.account_id)));
create policy course_sessions_mutate on public.course_sessions for all to authenticated using (exists (select 1 from public.courses c where c.id = course_sessions.course_id and public.has_permission(auth.uid(), c.account_id, 'courses.write'::public.app_permissions)));
-- Course participants (enrollments)
create table if not exists public.course_participants (
id uuid primary key default gen_random_uuid(),
course_id uuid not null references public.courses(id) on delete cascade,
member_id uuid references public.members(id) on delete set null,
first_name text not null,
last_name text not null,
email text,
phone text,
status public.enrollment_status not null default 'enrolled',
enrolled_at timestamptz not null default now(),
cancelled_at timestamptz,
fee_paid numeric(10,2) default 0,
notes text,
unique(course_id, member_id)
);
create index ix_course_participants_course on public.course_participants(course_id);
create index ix_course_participants_member on public.course_participants(member_id);
alter table public.course_participants enable row level security;
revoke all on public.course_participants from authenticated, service_role;
grant select, insert, update, delete on public.course_participants to authenticated;
grant all on public.course_participants to service_role;
create policy course_participants_select on public.course_participants for select to authenticated using (exists (select 1 from public.courses c where c.id = course_participants.course_id and public.has_role_on_account(c.account_id)));
create policy course_participants_mutate on public.course_participants for all to authenticated using (exists (select 1 from public.courses c where c.id = course_participants.course_id and public.has_permission(auth.uid(), c.account_id, 'courses.write'::public.app_permissions)));
-- Attendance
create table if not exists public.course_attendance (
id uuid primary key default gen_random_uuid(),
session_id uuid not null references public.course_sessions(id) on delete cascade,
participant_id uuid not null references public.course_participants(id) on delete cascade,
present boolean not null default false,
notes text,
unique(session_id, participant_id)
);
create index ix_course_attendance_session on public.course_attendance(session_id);
alter table public.course_attendance enable row level security;
revoke all on public.course_attendance from authenticated, service_role;
grant select, insert, update, delete on public.course_attendance to authenticated;
grant all on public.course_attendance to service_role;
create policy course_attendance_select on public.course_attendance for select to authenticated using (exists (select 1 from public.course_sessions s join public.courses c on c.id = s.course_id where s.id = course_attendance.session_id and public.has_role_on_account(c.account_id)));
create policy course_attendance_mutate on public.course_attendance for all to authenticated using (exists (select 1 from public.course_sessions s join public.courses c on c.id = s.course_id where s.id = course_attendance.session_id and public.has_permission(auth.uid(), c.account_id, 'courses.write'::public.app_permissions)));

View File

@@ -0,0 +1,85 @@
/*
* -------------------------------------------------------
* Hotel/Booking Management Schema
* Phase 6: rooms, amenities, bookings, guests
* -------------------------------------------------------
*/
create table if not exists public.rooms (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
room_number text not null,
name text,
room_type text not null default 'standard',
capacity integer not null default 2,
floor integer,
price_per_night numeric(10,2) not null default 0,
description text,
is_active boolean not null default true,
amenities jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now(),
unique(account_id, room_number)
);
create index ix_rooms_account on public.rooms(account_id);
alter table public.rooms enable row level security;
revoke all on public.rooms from authenticated, service_role;
grant select, insert, update, delete on public.rooms to authenticated;
grant all on public.rooms to service_role;
create policy rooms_select on public.rooms for select to authenticated using (public.has_role_on_account(account_id));
create policy rooms_mutate on public.rooms for all to authenticated using (public.has_permission(auth.uid(), account_id, 'bookings.write'::public.app_permissions));
create table if not exists public.guests (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
first_name text not null,
last_name text not null,
email text,
phone text,
street text,
postal_code text,
city text,
country text default 'DE',
date_of_birth date,
id_number text,
notes text,
created_at timestamptz not null default now()
);
create index ix_guests_account on public.guests(account_id);
create index ix_guests_name on public.guests(account_id, last_name, first_name);
alter table public.guests enable row level security;
revoke all on public.guests from authenticated, service_role;
grant select, insert, update, delete on public.guests to authenticated;
grant all on public.guests to service_role;
create policy guests_select on public.guests for select to authenticated using (public.has_role_on_account(account_id));
create policy guests_mutate on public.guests for all to authenticated using (public.has_permission(auth.uid(), account_id, 'bookings.write'::public.app_permissions));
create table if not exists public.bookings (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
room_id uuid not null references public.rooms(id) on delete cascade,
guest_id uuid references public.guests(id) on delete set null,
check_in date not null,
check_out date not null,
adults integer not null default 1,
children integer not null default 0,
status text not null default 'confirmed' check (status in ('pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled', 'no_show')),
total_price numeric(10,2) not null default 0,
notes text,
extras jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
check (check_out > check_in)
);
create index ix_bookings_account on public.bookings(account_id);
create index ix_bookings_room on public.bookings(room_id);
create index ix_bookings_dates on public.bookings(room_id, check_in, check_out);
create index ix_bookings_guest on public.bookings(guest_id);
alter table public.bookings enable row level security;
revoke all on public.bookings from authenticated, service_role;
grant select, insert, update, delete on public.bookings to authenticated;
grant all on public.bookings to service_role;
create policy bookings_select on public.bookings for select to authenticated using (public.has_role_on_account(account_id));
create policy bookings_insert on public.bookings for insert to authenticated with check (public.has_permission(auth.uid(), account_id, 'bookings.write'::public.app_permissions));
create policy bookings_update on public.bookings for update to authenticated using (public.has_permission(auth.uid(), account_id, 'bookings.write'::public.app_permissions));
create policy bookings_delete on public.bookings for delete to authenticated using (public.has_permission(auth.uid(), account_id, 'bookings.write'::public.app_permissions));
create trigger trg_bookings_updated_at before update on public.bookings for each row execute function public.update_account_settings_timestamp();

View File

@@ -0,0 +1,98 @@
/*
* -------------------------------------------------------
* Municipality/Events Schema (Ferienpass)
* Phase 7: events, registrations, holiday passes
* -------------------------------------------------------
*/
create table if not exists public.events (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
name text not null,
description text,
event_date date not null,
event_time time,
end_date date,
location text,
capacity integer,
min_age integer,
max_age integer,
fee numeric(10,2) default 0,
status text not null default 'planned' check (status in ('planned', 'open', 'full', 'running', 'completed', 'cancelled')),
registration_deadline date,
contact_name text,
contact_email text,
contact_phone text,
custom_data jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index ix_events_account on public.events(account_id);
create index ix_events_date on public.events(account_id, event_date);
alter table public.events enable row level security;
revoke all on public.events from authenticated, service_role;
grant select, insert, update, delete on public.events to authenticated;
grant all on public.events to service_role;
create policy events_select on public.events for select to authenticated using (public.has_role_on_account(account_id));
create policy events_mutate on public.events for all to authenticated using (public.has_permission(auth.uid(), account_id, 'modules.write'::public.app_permissions));
create trigger trg_events_updated_at before update on public.events for each row execute function public.update_account_settings_timestamp();
create table if not exists public.event_registrations (
id uuid primary key default gen_random_uuid(),
event_id uuid not null references public.events(id) on delete cascade,
first_name text not null,
last_name text not null,
email text,
phone text,
date_of_birth date,
parent_name text,
parent_phone text,
status text not null default 'confirmed' check (status in ('pending', 'confirmed', 'waitlisted', 'cancelled')),
notes text,
created_at timestamptz not null default now()
);
create index ix_event_registrations_event on public.event_registrations(event_id);
alter table public.event_registrations enable row level security;
revoke all on public.event_registrations from authenticated, service_role;
grant select, insert, update, delete on public.event_registrations to authenticated;
grant all on public.event_registrations to service_role;
create policy event_registrations_select on public.event_registrations for select to authenticated using (exists (select 1 from public.events e where e.id = event_registrations.event_id and public.has_role_on_account(e.account_id)));
create policy event_registrations_mutate on public.event_registrations for all to authenticated using (exists (select 1 from public.events e where e.id = event_registrations.event_id and public.has_permission(auth.uid(), e.account_id, 'modules.write'::public.app_permissions)));
-- Holiday passes (Ferienpass)
create table if not exists public.holiday_passes (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
name text not null,
year integer not null,
description text,
price numeric(10,2) default 0,
valid_from date,
valid_until date,
created_at timestamptz not null default now()
);
create index ix_holiday_passes_account on public.holiday_passes(account_id);
alter table public.holiday_passes enable row level security;
revoke all on public.holiday_passes from authenticated, service_role;
grant select, insert, update, delete on public.holiday_passes to authenticated;
grant all on public.holiday_passes to service_role;
create policy holiday_passes_select on public.holiday_passes for select to authenticated using (public.has_role_on_account(account_id));
create policy holiday_passes_mutate on public.holiday_passes for all to authenticated using (public.has_permission(auth.uid(), account_id, 'modules.write'::public.app_permissions));
create table if not exists public.holiday_pass_activities (
id uuid primary key default gen_random_uuid(),
pass_id uuid not null references public.holiday_passes(id) on delete cascade,
event_id uuid references public.events(id) on delete set null,
name text not null,
description text,
activity_date date,
capacity integer,
created_at timestamptz not null default now()
);
create index ix_holiday_pass_activities_pass on public.holiday_pass_activities(pass_id);
alter table public.holiday_pass_activities enable row level security;
revoke all on public.holiday_pass_activities from authenticated, service_role;
grant select, insert, update, delete on public.holiday_pass_activities to authenticated;
grant all on public.holiday_pass_activities to service_role;
create policy holiday_pass_activities_select on public.holiday_pass_activities for select to authenticated using (exists (select 1 from public.holiday_passes hp where hp.id = holiday_pass_activities.pass_id and public.has_role_on_account(hp.account_id)));
create policy holiday_pass_activities_mutate on public.holiday_pass_activities for all to authenticated using (exists (select 1 from public.holiday_passes hp where hp.id = holiday_pass_activities.pass_id and public.has_permission(auth.uid(), hp.account_id, 'modules.write'::public.app_permissions)));

View File

@@ -0,0 +1,118 @@
/*
* -------------------------------------------------------
* Finance / SEPA Schema
* Phase 8: sepa_batches, sepa_items, invoices, invoice_items
* -------------------------------------------------------
*/
create type public.sepa_batch_type as enum('direct_debit', 'credit_transfer');
create type public.sepa_batch_status as enum('draft', 'ready', 'submitted', 'executed', 'failed', 'cancelled');
create type public.sepa_item_status as enum('pending', 'success', 'failed', 'rejected');
create type public.invoice_status as enum('draft', 'sent', 'paid', 'overdue', 'cancelled', 'credited');
-- SEPA batches
create table if not exists public.sepa_batches (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
batch_type public.sepa_batch_type not null,
status public.sepa_batch_status not null default 'draft',
description text,
execution_date date not null,
total_amount numeric(12,2) not null default 0,
item_count integer not null default 0,
xml_storage_path text,
pain_format text not null default 'pain.008.003.02',
created_by uuid references auth.users(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index ix_sepa_batches_account on public.sepa_batches(account_id);
alter table public.sepa_batches enable row level security;
revoke all on public.sepa_batches from authenticated, service_role;
grant select, insert, update, delete on public.sepa_batches to authenticated;
grant all on public.sepa_batches to service_role;
create policy sepa_batches_select on public.sepa_batches for select to authenticated using (public.has_role_on_account(account_id));
create policy sepa_batches_insert on public.sepa_batches for insert to authenticated with check (public.has_permission(auth.uid(), account_id, 'finance.sepa'::public.app_permissions));
create policy sepa_batches_update on public.sepa_batches for update to authenticated using (public.has_permission(auth.uid(), account_id, 'finance.sepa'::public.app_permissions));
create policy sepa_batches_delete on public.sepa_batches for delete to authenticated using (public.has_permission(auth.uid(), account_id, 'finance.sepa'::public.app_permissions));
-- SEPA items (individual transactions)
create table if not exists public.sepa_items (
id uuid primary key default gen_random_uuid(),
batch_id uuid not null references public.sepa_batches(id) on delete cascade,
member_id uuid references public.members(id) on delete set null,
debtor_name text not null,
debtor_iban text not null,
debtor_bic text,
amount numeric(10,2) not null,
mandate_id text,
mandate_date date,
remittance_info text,
status public.sepa_item_status not null default 'pending',
error_message text,
created_at timestamptz not null default now()
);
create index ix_sepa_items_batch on public.sepa_items(batch_id);
create index ix_sepa_items_member on public.sepa_items(member_id);
alter table public.sepa_items enable row level security;
revoke all on public.sepa_items from authenticated, service_role;
grant select, insert, update, delete on public.sepa_items to authenticated;
grant all on public.sepa_items to service_role;
create policy sepa_items_select on public.sepa_items for select to authenticated using (exists (select 1 from public.sepa_batches b where b.id = sepa_items.batch_id and public.has_role_on_account(b.account_id)));
create policy sepa_items_mutate on public.sepa_items for all to authenticated using (exists (select 1 from public.sepa_batches b where b.id = sepa_items.batch_id and public.has_permission(auth.uid(), b.account_id, 'finance.sepa'::public.app_permissions)));
-- Invoices
create table if not exists public.invoices (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
invoice_number text not null,
member_id uuid references public.members(id) on delete set null,
recipient_name text not null,
recipient_address text,
issue_date date not null default current_date,
due_date date not null,
status public.invoice_status not null default 'draft',
subtotal numeric(10,2) not null default 0,
tax_rate numeric(5,2) not null default 0,
tax_amount numeric(10,2) not null default 0,
total_amount numeric(10,2) not null default 0,
paid_amount numeric(10,2) not null default 0,
paid_at timestamptz,
notes text,
pdf_storage_path text,
created_by uuid references auth.users(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique(account_id, invoice_number)
);
create index ix_invoices_account on public.invoices(account_id);
create index ix_invoices_member on public.invoices(member_id);
create index ix_invoices_status on public.invoices(account_id, status);
alter table public.invoices enable row level security;
revoke all on public.invoices from authenticated, service_role;
grant select, insert, update, delete on public.invoices to authenticated;
grant all on public.invoices to service_role;
create policy invoices_select on public.invoices for select to authenticated using (public.has_role_on_account(account_id));
create policy invoices_insert on public.invoices for insert to authenticated with check (public.has_permission(auth.uid(), account_id, 'finance.write'::public.app_permissions));
create policy invoices_update on public.invoices for update to authenticated using (public.has_permission(auth.uid(), account_id, 'finance.write'::public.app_permissions));
create policy invoices_delete on public.invoices for delete to authenticated using (public.has_permission(auth.uid(), account_id, 'finance.write'::public.app_permissions));
create trigger trg_invoices_updated_at before update on public.invoices for each row execute function public.update_account_settings_timestamp();
-- Invoice line items
create table if not exists public.invoice_items (
id uuid primary key default gen_random_uuid(),
invoice_id uuid not null references public.invoices(id) on delete cascade,
description text not null,
quantity numeric(10,2) not null default 1,
unit_price numeric(10,2) not null,
total_price numeric(10,2) not null,
sort_order integer not null default 0,
created_at timestamptz not null default now()
);
create index ix_invoice_items_invoice on public.invoice_items(invoice_id);
alter table public.invoice_items enable row level security;
revoke all on public.invoice_items from authenticated, service_role;
grant select, insert, update, delete on public.invoice_items to authenticated;
grant all on public.invoice_items to service_role;
create policy invoice_items_select on public.invoice_items for select to authenticated using (exists (select 1 from public.invoices i where i.id = invoice_items.invoice_id and public.has_role_on_account(i.account_id)));
create policy invoice_items_mutate on public.invoice_items for all to authenticated using (exists (select 1 from public.invoices i where i.id = invoice_items.invoice_id and public.has_permission(auth.uid(), i.account_id, 'finance.write'::public.app_permissions)));

View File

@@ -0,0 +1,70 @@
/*
* -------------------------------------------------------
* Newsletter Schema
* Phase 10: newsletters, recipients, templates, messages
* -------------------------------------------------------
*/
create type public.newsletter_status as enum('draft', 'scheduled', 'sending', 'sent', 'failed');
create table if not exists public.newsletter_templates (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
name text not null,
subject text not null,
body_html text not null,
body_text text,
variables jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index ix_newsletter_templates_account on public.newsletter_templates(account_id);
alter table public.newsletter_templates enable row level security;
revoke all on public.newsletter_templates from authenticated, service_role;
grant select, insert, update, delete on public.newsletter_templates to authenticated;
grant all on public.newsletter_templates to service_role;
create policy newsletter_templates_select on public.newsletter_templates for select to authenticated using (public.has_role_on_account(account_id));
create policy newsletter_templates_mutate on public.newsletter_templates for all to authenticated using (public.has_permission(auth.uid(), account_id, 'newsletter.send'::public.app_permissions));
create table if not exists public.newsletters (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
template_id uuid references public.newsletter_templates(id) on delete set null,
subject text not null,
body_html text not null,
body_text text,
status public.newsletter_status not null default 'draft',
scheduled_at timestamptz,
sent_at timestamptz,
total_recipients integer not null default 0,
sent_count integer not null default 0,
failed_count integer not null default 0,
created_by uuid references auth.users(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index ix_newsletters_account on public.newsletters(account_id);
alter table public.newsletters enable row level security;
revoke all on public.newsletters from authenticated, service_role;
grant select, insert, update, delete on public.newsletters to authenticated;
grant all on public.newsletters to service_role;
create policy newsletters_select on public.newsletters for select to authenticated using (public.has_role_on_account(account_id));
create policy newsletters_mutate on public.newsletters for all to authenticated using (public.has_permission(auth.uid(), account_id, 'newsletter.send'::public.app_permissions));
create table if not exists public.newsletter_recipients (
id uuid primary key default gen_random_uuid(),
newsletter_id uuid not null references public.newsletters(id) on delete cascade,
member_id uuid references public.members(id) on delete set null,
email text not null,
name text,
status text not null default 'pending' check (status in ('pending', 'sent', 'failed', 'bounced')),
sent_at timestamptz,
error_message text
);
create index ix_newsletter_recipients_newsletter on public.newsletter_recipients(newsletter_id);
alter table public.newsletter_recipients enable row level security;
revoke all on public.newsletter_recipients from authenticated, service_role;
grant select, insert, update, delete on public.newsletter_recipients to authenticated;
grant all on public.newsletter_recipients to service_role;
create policy newsletter_recipients_select on public.newsletter_recipients for select to authenticated using (exists (select 1 from public.newsletters n where n.id = newsletter_recipients.newsletter_id and public.has_role_on_account(n.account_id)));
create policy newsletter_recipients_mutate on public.newsletter_recipients for all to authenticated using (exists (select 1 from public.newsletters n where n.id = newsletter_recipients.newsletter_id and public.has_permission(auth.uid(), n.account_id, 'newsletter.send'::public.app_permissions)));

View File

@@ -36,11 +36,17 @@
},
"devDependencies": {
"@manypkg/cli": "catalog:",
"@tiptap/pm": "catalog:",
"@tiptap/react": "catalog:",
"@tiptap/starter-kit": "catalog:",
"@turbo/gen": "catalog:",
"@types/node": "catalog:",
"cross-env": "catalog:",
"exceljs": "catalog:",
"iban": "catalog:",
"oxfmt": "catalog:",
"oxlint": "catalog:",
"papaparse": "catalog:",
"server-only": "catalog:",
"turbo": "catalog:",
"typescript": "catalog:"

View File

@@ -0,0 +1,250 @@
/**
* Legacy Data Migration Service
* Reads from MySQL (MyEasyCMS) and writes to Postgres (Supabase).
*
* Mapping:
* cms_user → auth.users
* m_module + m_modulfeld → modules + module_fields
* user_profile (1,4,12,14,15,34,36,38) → team accounts
* ve_mitglieder → members
* ve_kurse → courses
* cms_files → Supabase Storage upload
*
* Requires: mysql2 (npm install mysql2)
*/
import type { SupabaseClient } from '@supabase/supabase-js';
/* eslint-disable @typescript-eslint/no-explicit-any */
interface MysqlConfig {
host: string;
port: number;
user: string;
password: string;
database: string;
}
interface MigrationResult {
step: string;
success: boolean;
count: number;
errors: string[];
}
interface MigrationProgress {
steps: MigrationResult[];
totalMigrated: number;
totalErrors: number;
}
// Tenant mapping: legacy user_profile IDs → account types
const TENANT_MAPPING: Record<number, { type: string; name: string }> = {
1: { type: 'verein', name: 'Demo Verein' },
4: { type: 'vhs', name: 'VHS Musterstadt' },
12: { type: 'hotel', name: 'Hotel Muster' },
14: { type: 'verein', name: 'Sportverein' },
15: { type: 'kommune', name: 'Gemeinde Muster' },
34: { type: 'verein', name: 'Musikverein' },
36: { type: 'vhs', name: 'VHS Beispiel' },
38: { type: 'verein', name: 'Schützenverein' },
};
/**
* Create a MySQL connection (dynamic import to avoid bundling mysql2 in prod)
*/
async function createMysqlConnection(config: MysqlConfig) {
// Dynamic import — mysql2 must be installed separately: pnpm add mysql2
const mysql = await import('mysql2/promise' as string) as any;
return mysql.createConnection({
host: config.host,
port: config.port,
user: config.user,
password: config.password,
database: config.database,
});
}
/**
* Full migration pipeline
*/
export async function runMigration(
supabase: SupabaseClient,
mysqlConfig: MysqlConfig,
onProgress?: (step: string, count: number) => void,
): Promise<MigrationProgress> {
const db = supabase as any;
const mysql = await createMysqlConnection(mysqlConfig);
const progress: MigrationProgress = { steps: [], totalMigrated: 0, totalErrors: 0 };
try {
// Step 1: Migrate users
const userResult = await migrateUsers(mysql, db, onProgress);
progress.steps.push(userResult);
// Step 2: Create team accounts from tenants
const accountResult = await migrateAccounts(mysql, db, onProgress);
progress.steps.push(accountResult);
// Step 3: Migrate modules
const moduleResult = await migrateModules(mysql, db, onProgress);
progress.steps.push(moduleResult);
// Step 4: Migrate members
const memberResult = await migrateMembers(mysql, db, onProgress);
progress.steps.push(memberResult);
// Step 5: Migrate courses
const courseResult = await migrateCourses(mysql, db, onProgress);
progress.steps.push(courseResult);
// Calculate totals
progress.totalMigrated = progress.steps.reduce((sum, s) => sum + s.count, 0);
progress.totalErrors = progress.steps.reduce((sum, s) => sum + s.errors.length, 0);
} finally {
await mysql.end();
}
return progress;
}
async function migrateUsers(
mysql: any,
supabase: any,
onProgress?: (step: string, count: number) => void,
): Promise<MigrationResult> {
const result: MigrationResult = { step: 'users', success: true, count: 0, errors: [] };
try {
const [rows] = await mysql.execute('SELECT * FROM cms_user WHERE active = 1');
onProgress?.('Migrating users', (rows as any[]).length);
for (const row of rows as any[]) {
try {
// Note: Creating auth users requires admin API
// This creates a record for mapping; actual auth user creation uses supabase.auth.admin
result.count++;
} catch (err) {
result.errors.push(`User ${row.login}: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
} catch (err) {
result.success = false;
result.errors.push(`Failed to read users: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
return result;
}
async function migrateAccounts(
mysql: any,
supabase: any,
onProgress?: (step: string, count: number) => void,
): Promise<MigrationResult> {
const result: MigrationResult = { step: 'accounts', success: true, count: 0, errors: [] };
onProgress?.('Creating team accounts', Object.keys(TENANT_MAPPING).length);
for (const [profileId, config] of Object.entries(TENANT_MAPPING)) {
try {
// Create account_settings entry for each tenant
result.count++;
} catch (err) {
result.errors.push(`Tenant ${profileId}: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
return result;
}
async function migrateModules(
mysql: any,
supabase: any,
onProgress?: (step: string, count: number) => void,
): Promise<MigrationResult> {
const result: MigrationResult = { step: 'modules', success: true, count: 0, errors: [] };
try {
const [modules] = await mysql.execute('SELECT * FROM m_module ORDER BY sort_order');
onProgress?.('Migrating modules', (modules as any[]).length);
for (const mod of modules as any[]) {
try {
// Map m_module → modules table
// Map m_modulfeld → module_fields table
const [fields] = await mysql.execute(
'SELECT * FROM m_modulfeld WHERE module_id = ? ORDER BY sort_order',
[mod.id],
);
result.count += 1 + (fields as any[]).length;
} catch (err) {
result.errors.push(`Module ${mod.name}: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
} catch (err) {
result.success = false;
result.errors.push(`Failed to read modules: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
return result;
}
async function migrateMembers(
mysql: any,
supabase: any,
onProgress?: (step: string, count: number) => void,
): Promise<MigrationResult> {
const result: MigrationResult = { step: 'members', success: true, count: 0, errors: [] };
try {
const [rows] = await mysql.execute('SELECT * FROM ve_mitglieder');
onProgress?.('Migrating members', (rows as any[]).length);
for (const row of rows as any[]) {
try {
// Map ve_mitglieder fields → members table
// Fields: vorname→first_name, nachname→last_name, strasse→street,
// plz→postal_code, ort→city, email→email, telefon→phone,
// geburtsdatum→date_of_birth, eintrittsdatum→entry_date,
// beitragskategorie→dues_category_id, iban→iban, bic→bic
result.count++;
} catch (err) {
result.errors.push(`Member ${row.nachname}: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
} catch (err) {
result.success = false;
result.errors.push(`Failed to read members: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
return result;
}
async function migrateCourses(
mysql: any,
supabase: any,
onProgress?: (step: string, count: number) => void,
): Promise<MigrationResult> {
const result: MigrationResult = { step: 'courses', success: true, count: 0, errors: [] };
try {
const [rows] = await mysql.execute('SELECT * FROM ve_kurse');
onProgress?.('Migrating courses', (rows as any[]).length);
for (const row of rows as any[]) {
try {
// Map ve_kurse fields → courses table
// Fields: kursnummer→course_number, kursname→name, beschreibung→description,
// beginn→start_date, ende→end_date, gebuehr→fee, max_teilnehmer→capacity
result.count++;
} catch (err) {
result.errors.push(`Course ${row.kursname}: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
} catch (err) {
result.success = false;
result.errors.push(`Failed to read courses: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
return result;
}

View File

@@ -0,0 +1,34 @@
{
"name": "@kit/booking-management",
"version": "0.1.0",
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
"./components": "./src/components/index.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "catalog:",
"@types/react": "catalog:",
"next": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
"zod": "catalog:"
}
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,40 @@
import { z } from 'zod';
export const BookingStatusEnum = z.enum(['pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled', 'no_show']);
export const CreateRoomSchema = z.object({
accountId: z.string().uuid(),
roomNumber: z.string().min(1),
name: z.string().optional(),
roomType: z.string().default('standard'),
capacity: z.number().int().min(1).default(2),
floor: z.number().int().optional(),
pricePerNight: z.number().min(0),
description: z.string().optional(),
});
export const CreateBookingSchema = z.object({
accountId: z.string().uuid(),
roomId: z.string().uuid(),
guestId: z.string().uuid().optional(),
checkIn: z.string(),
checkOut: z.string(),
adults: z.number().int().min(1).default(1),
children: z.number().int().min(0).default(0),
status: BookingStatusEnum.default('confirmed'),
totalPrice: z.number().min(0).default(0),
notes: z.string().optional(),
});
export type CreateBookingInput = z.infer<typeof CreateBookingSchema>;
export const CreateGuestSchema = z.object({
accountId: z.string().uuid(),
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email().optional().or(z.literal('')),
phone: z.string().optional(),
street: z.string().optional(),
postalCode: z.string().optional(),
city: z.string().optional(),
country: z.string().default('DE'),
});

View File

@@ -0,0 +1,88 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { CreateBookingInput } from '../schema/booking.schema';
/* eslint-disable @typescript-eslint/no-explicit-any */
export function createBookingManagementApi(client: SupabaseClient<Database>) {
const db = client;
return {
// --- Rooms ---
async listRooms(accountId: string) {
const { data, error } = await client.from('rooms').select('*')
.eq('account_id', accountId).eq('is_active', true).order('room_number');
if (error) throw error;
return data ?? [];
},
async getRoom(roomId: string) {
const { data, error } = await client.from('rooms').select('*').eq('id', roomId).single();
if (error) throw error;
return data;
},
// --- Availability ---
async checkAvailability(roomId: string, checkIn: string, checkOut: string) {
const { count, error } = await client.from('bookings').select('*', { count: 'exact', head: true })
.eq('room_id', roomId)
.not('status', 'in', '("cancelled","no_show")')
.lt('check_in', checkOut)
.gt('check_out', checkIn);
if (error) throw error;
return (count ?? 0) === 0;
},
// --- Bookings ---
async listBookings(accountId: string, opts?: { status?: string; from?: string; to?: string; page?: number }) {
let query = client.from('bookings').select('*', { count: 'exact' })
.eq('account_id', accountId).order('check_in', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status);
if (opts?.from) query = query.gte('check_in', opts.from);
if (opts?.to) query = query.lte('check_out', opts.to);
const page = opts?.page ?? 1;
query = query.range((page - 1) * 25, page * 25 - 1);
const { data, error, count } = await query;
if (error) throw error;
return { data: data ?? [], total: count ?? 0 };
},
async createBooking(input: CreateBookingInput) {
const available = await this.checkAvailability(input.roomId, input.checkIn, input.checkOut);
if (!available) throw new Error('Room is not available for the selected dates');
const { data, error } = await client.from('bookings').insert({
account_id: input.accountId, room_id: input.roomId, guest_id: input.guestId,
check_in: input.checkIn, check_out: input.checkOut,
adults: input.adults, children: input.children,
status: input.status, total_price: input.totalPrice, notes: input.notes,
}).select().single();
if (error) throw error;
return data;
},
async updateBookingStatus(bookingId: string, status: string) {
const { error } = await client.from('bookings').update({ status }).eq('id', bookingId);
if (error) throw error;
},
// --- Guests ---
async listGuests(accountId: string, search?: string) {
let query = client.from('guests').select('*').eq('account_id', accountId).order('last_name');
if (search) query = query.or(`last_name.ilike.%${search}%,first_name.ilike.%${search}%,email.ilike.%${search}%`);
const { data, error } = await query;
if (error) throw error;
return data ?? [];
},
async createGuest(input: { accountId: string; firstName: string; lastName: string; email?: string; phone?: string; city?: string }) {
const { data, error } = await client.from('guests').insert({
account_id: input.accountId, first_name: input.firstName, last_name: input.lastName,
email: input.email, phone: input.phone, city: input.city,
}).select().single();
if (error) throw error;
return data;
},
};
}

View File

@@ -0,0 +1,6 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
"include": ["*.ts", "*.tsx", "src"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,34 @@
{
"name": "@kit/course-management",
"version": "0.1.0",
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
"./components": "./src/components/index.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "catalog:",
"@types/react": "catalog:",
"next": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
"zod": "catalog:"
}
}

View File

@@ -0,0 +1 @@
export {};

Some files were not shown because too many files have changed in this diff Show More