Initial state for GitNexus analysis
This commit is contained in:
25
apps/e2e/tests/course-enrollment.spec.ts
Normal file
25
apps/e2e/tests/course-enrollment.spec.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
29
apps/e2e/tests/member-lifecycle.spec.ts
Normal file
29
apps/e2e/tests/member-lifecycle.spec.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
31
apps/e2e/tests/module-builder.spec.ts
Normal file
31
apps/e2e/tests/module-builder.spec.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
20
apps/e2e/tests/newsletter.spec.ts
Normal file
20
apps/e2e/tests/newsletter.spec.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
25
apps/e2e/tests/sepa-batch.spec.ts
Normal file
25
apps/e2e/tests/sepa-batch.spec.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
20
apps/web/app/[locale]/admin/audit/page.tsx
Normal file
20
apps/web/app/[locale]/admin/audit/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/web/app/[locale]/admin/gdpr/page.tsx
Normal file
20
apps/web/app/[locale]/admin/gdpr/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/web/app/[locale]/admin/migration/page.tsx
Normal file
33
apps/web/app/[locale]/admin/migration/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/web/app/[locale]/admin/modules/page.tsx
Normal file
19
apps/web/app/[locale]/admin/modules/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 "{bookingId}" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx
Normal file
230
apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
235
apps/web/app/[locale]/home/[account]/bookings/new/page.tsx
Normal file
235
apps/web/app/[locale]/home/[account]/bookings/new/page.tsx
Normal 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 & 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
apps/web/app/[locale]/home/[account]/bookings/page.tsx
Normal file
182
apps/web/app/[locale]/home/[account]/bookings/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
apps/web/app/[locale]/home/[account]/bookings/rooms/page.tsx
Normal file
101
apps/web/app/[locale]/home/[account]/bookings/rooms/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx
Normal file
188
apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
apps/web/app/[locale]/home/[account]/courses/calendar/page.tsx
Normal file
248
apps/web/app/[locale]/home/[account]/courses/calendar/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
apps/web/app/[locale]/home/[account]/courses/new/page.tsx
Normal file
191
apps/web/app/[locale]/home/[account]/courses/new/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
apps/web/app/[locale]/home/[account]/courses/page.tsx
Normal file
180
apps/web/app/[locale]/home/[account]/courses/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
apps/web/app/[locale]/home/[account]/documents/generate/page.tsx
Normal file
157
apps/web/app/[locale]/home/[account]/documents/generate/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
apps/web/app/[locale]/home/[account]/documents/page.tsx
Normal file
137
apps/web/app/[locale]/home/[account]/documents/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
apps/web/app/[locale]/home/[account]/events/[eventId]/page.tsx
Normal file
182
apps/web/app/[locale]/home/[account]/events/[eventId]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
301
apps/web/app/[locale]/home/[account]/events/new/page.tsx
Normal file
301
apps/web/app/[locale]/home/[account]/events/new/page.tsx
Normal 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 & 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
apps/web/app/[locale]/home/[account]/events/page.tsx
Normal file
180
apps/web/app/[locale]/home/[account]/events/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
apps/web/app/[locale]/home/[account]/finance/invoices/page.tsx
Normal file
162
apps/web/app/[locale]/home/[account]/finance/invoices/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
apps/web/app/[locale]/home/[account]/finance/page.tsx
Normal file
265
apps/web/app/[locale]/home/[account]/finance/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
apps/web/app/[locale]/home/[account]/finance/payments/page.tsx
Normal file
166
apps/web/app/[locale]/home/[account]/finance/payments/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
apps/web/app/[locale]/home/[account]/finance/sepa/new/page.tsx
Normal file
117
apps/web/app/[locale]/home/[account]/finance/sepa/new/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
apps/web/app/[locale]/home/[account]/finance/sepa/page.tsx
Normal file
164
apps/web/app/[locale]/home/[account]/finance/sepa/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx
Normal file
183
apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
apps/web/app/[locale]/home/[account]/members-cms/page.tsx
Normal file
66
apps/web/app/[locale]/home/[account]/members-cms/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
apps/web/app/[locale]/home/[account]/modules/page.tsx
Normal file
66
apps/web/app/[locale]/home/[account]/modules/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
apps/web/app/[locale]/home/[account]/newsletter/new/page.tsx
Normal file
125
apps/web/app/[locale]/home/[account]/newsletter/new/page.tsx
Normal 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> <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>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
apps/web/app/[locale]/home/[account]/newsletter/page.tsx
Normal file
178
apps/web/app/[locale]/home/[account]/newsletter/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { PageBody } from '@kit/ui/page';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Button } from '@kit/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@kit/ui/card';
|
||||||
|
|
||||||
import { DashboardDemo } from './_components/dashboard-demo';
|
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||||
import { TeamAccountLayoutPageHeader } from './_components/team-account-layout-page-header';
|
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 {
|
interface TeamAccountHomePageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const generateMetadata = async () => {
|
export default async function TeamAccountHomePage({
|
||||||
const t = await getTranslations('teams');
|
params,
|
||||||
const title = t('home.pageTitle');
|
}: TeamAccountHomePageProps) {
|
||||||
|
const { account } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
return {
|
const { data: acct } = await client
|
||||||
title,
|
.from('accounts')
|
||||||
};
|
.select('id, name')
|
||||||
};
|
.eq('slug', account)
|
||||||
|
.single();
|
||||||
|
|
||||||
function TeamAccountHomePage({ params }: TeamAccountHomePageProps) {
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
const account = use(params).account;
|
|
||||||
|
// 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 (
|
return (
|
||||||
<PageBody>
|
<CmsPageShell account={account} title={accountName}>
|
||||||
<TeamAccountLayoutPageHeader
|
<div className="flex w-full flex-col gap-6">
|
||||||
account={account}
|
{/* Stats Row */}
|
||||||
title={<Trans i18nKey={'common.routes.dashboard'} />}
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
description={<AppBreadcrumbs />}
|
<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 />
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
</PageBody>
|
{/* 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;
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import '../styles/globals.css';
|
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) {
|
export default function RootLayout({ children }: React.PropsWithChildren) {
|
||||||
return children;
|
return children;
|
||||||
|
|||||||
25
apps/web/components.json
Normal file
25
apps/web/components.json
Normal 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": {}
|
||||||
|
}
|
||||||
33
apps/web/components/cms-page-shell.tsx
Normal file
33
apps/web/components/cms-page-shell.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
apps/web/components/empty-state.tsx
Normal file
39
apps/web/components/empty-state.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
apps/web/components/stats-card.tsx
Normal file
44
apps/web/components/stats-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
apps/web/config/billing-plans.config.ts
Normal file
97
apps/web/config/billing-plans.config.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -41,6 +41,15 @@ const FeatureFlagsSchema = z.object({
|
|||||||
enableTeamsOnly: z.boolean({
|
enableTeamsOnly: z.boolean({
|
||||||
error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY',
|
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({
|
const featuresFlagConfig = FeatureFlagsSchema.parse({
|
||||||
@@ -90,6 +99,39 @@ const featuresFlagConfig = FeatureFlagsSchema.parse({
|
|||||||
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY,
|
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY,
|
||||||
false,
|
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>);
|
} satisfies z.output<typeof FeatureFlagsSchema>);
|
||||||
|
|
||||||
export default featuresFlagConfig;
|
export default featuresFlagConfig;
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ const PathsSchema = z.object({
|
|||||||
accountProfileSettings: z.string().min(1),
|
accountProfileSettings: z.string().min(1),
|
||||||
createTeam: z.string().min(1),
|
createTeam: z.string().min(1),
|
||||||
joinTeam: 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`,
|
accountProfileSettings: `/home/[account]/settings/profile`,
|
||||||
createTeam: '/home/create-team',
|
createTeam: '/home/create-team',
|
||||||
joinTeam: '/join',
|
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>);
|
} satisfies z.output<typeof PathsSchema>);
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
|
||||||
|
|
||||||
@@ -17,7 +21,61 @@ const getRoutes = (account: string) => [
|
|||||||
Icon: <LayoutDashboard className={iconClasses} />,
|
Icon: <LayoutDashboard className={iconClasses} />,
|
||||||
highlightMatch: `${pathsConfig.app.home}$`,
|
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',
|
label: 'common.routes.settings',
|
||||||
|
|||||||
149
apps/web/i18n/messages/de/account.json
Normal file
149
apps/web/i18n/messages/de/account.json
Normal 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"
|
||||||
|
}
|
||||||
119
apps/web/i18n/messages/de/auth.json
Normal file
119
apps/web/i18n/messages/de/auth.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
127
apps/web/i18n/messages/de/billing.json
Normal file
127
apps/web/i18n/messages/de/billing.json
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
271
apps/web/i18n/messages/de/cms.json
Normal file
271
apps/web/i18n/messages/de/cms.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
123
apps/web/i18n/messages/de/common.json
Normal file
123
apps/web/i18n/messages/de/common.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/web/i18n/messages/de/marketing.json
Normal file
46
apps/web/i18n/messages/de/marketing.json
Normal 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."
|
||||||
|
}
|
||||||
184
apps/web/i18n/messages/de/teams.json
Normal file
184
apps/web/i18n/messages/de/teams.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
271
apps/web/i18n/messages/en/cms.json
Normal file
271
apps/web/i18n/messages/en/cms.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,7 +66,15 @@
|
|||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"application": "Application"
|
"application": "Application",
|
||||||
|
"modules": "Modules",
|
||||||
|
"cmsMembers": "Members",
|
||||||
|
"courses": "Courses",
|
||||||
|
"bookings": "Bookings",
|
||||||
|
"events": "Events",
|
||||||
|
"finance": "Finance",
|
||||||
|
"documents": "Documents",
|
||||||
|
"newsletter": "Newsletter"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"owner": {
|
"owner": {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const namespaces = [
|
|||||||
'teams',
|
'teams',
|
||||||
'billing',
|
'billing',
|
||||||
'marketing',
|
'marketing',
|
||||||
|
'cms',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
24
apps/web/lib/resolve-account.ts
Normal file
24
apps/web/lib/resolve-account.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"supabase:db:dump:local": "supabase db dump --local --data-only"
|
"supabase:db:dump:local": "supabase db dump --local --data-only"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@base-ui/react": "catalog:",
|
||||||
"@hookform/resolvers": "catalog:",
|
"@hookform/resolvers": "catalog:",
|
||||||
"@kit/accounts": "workspace:*",
|
"@kit/accounts": "workspace:*",
|
||||||
"@kit/admin": "workspace:*",
|
"@kit/admin": "workspace:*",
|
||||||
@@ -35,12 +36,20 @@
|
|||||||
"@kit/auth": "workspace:*",
|
"@kit/auth": "workspace:*",
|
||||||
"@kit/billing": "workspace:*",
|
"@kit/billing": "workspace:*",
|
||||||
"@kit/billing-gateway": "workspace:*",
|
"@kit/billing-gateway": "workspace:*",
|
||||||
|
"@kit/booking-management": "workspace:*",
|
||||||
"@kit/cms": "workspace:*",
|
"@kit/cms": "workspace:*",
|
||||||
|
"@kit/course-management": "workspace:*",
|
||||||
"@kit/database-webhooks": "workspace:*",
|
"@kit/database-webhooks": "workspace:*",
|
||||||
|
"@kit/document-generator": "workspace:*",
|
||||||
"@kit/email-templates": "workspace:*",
|
"@kit/email-templates": "workspace:*",
|
||||||
|
"@kit/event-management": "workspace:*",
|
||||||
|
"@kit/finance": "workspace:*",
|
||||||
"@kit/i18n": "workspace:*",
|
"@kit/i18n": "workspace:*",
|
||||||
"@kit/mailers": "workspace:*",
|
"@kit/mailers": "workspace:*",
|
||||||
|
"@kit/member-management": "workspace:*",
|
||||||
|
"@kit/module-builder": "workspace:*",
|
||||||
"@kit/monitoring": "workspace:*",
|
"@kit/monitoring": "workspace:*",
|
||||||
|
"@kit/newsletter": "workspace:*",
|
||||||
"@kit/next": "workspace:*",
|
"@kit/next": "workspace:*",
|
||||||
"@kit/notifications": "workspace:*",
|
"@kit/notifications": "workspace:*",
|
||||||
"@kit/shared": "workspace:*",
|
"@kit/shared": "workspace:*",
|
||||||
@@ -54,6 +63,8 @@
|
|||||||
"@supabase/supabase-js": "catalog:",
|
"@supabase/supabase-js": "catalog:",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tanstack/react-table": "catalog:",
|
"@tanstack/react-table": "catalog:",
|
||||||
|
"class-variance-authority": "catalog:",
|
||||||
|
"clsx": "catalog:",
|
||||||
"date-fns": "catalog:",
|
"date-fns": "catalog:",
|
||||||
"lucide-react": "catalog:",
|
"lucide-react": "catalog:",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
@@ -66,6 +77,7 @@
|
|||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"react-hook-form": "catalog:",
|
"react-hook-form": "catalog:",
|
||||||
"recharts": "catalog:",
|
"recharts": "catalog:",
|
||||||
|
"shadcn": "^4.1.1",
|
||||||
"tailwind-merge": "catalog:",
|
"tailwind-merge": "catalog:",
|
||||||
"tw-animate-css": "catalog:",
|
"tw-animate-css": "catalog:",
|
||||||
"urlpattern-polyfill": "catalog:",
|
"urlpattern-polyfill": "catalog:",
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
/*
|
/*
|
||||||
* global.css
|
* globals.css — Main CSS entry point
|
||||||
*
|
* Theme: shadcn preset b1G3f4DGS (Nova + Inter)
|
||||||
* Global styles for the entire application
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Tailwind CSS */
|
/* Tailwind CSS */
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
|
@import 'shadcn/tailwind.css';
|
||||||
|
|
||||||
/* local styles - update the below if you add a new style */
|
/* local styles */
|
||||||
@import './theme.css';
|
|
||||||
@import './shadcn-ui.css';
|
@import './shadcn-ui.css';
|
||||||
@import './markdoc.css';
|
@import './markdoc.css';
|
||||||
@import './makerkit.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/*/src/**/*.{ts,tsx}';
|
||||||
@source '../../../packages/features/*/src/**/*.{ts,tsx}';
|
@source '../../../packages/features/*/src/**/*.{ts,tsx}';
|
||||||
@source '../../../packages/billing/*/src/**/*.{ts,tsx}';
|
@source '../../../packages/billing/*/src/**/*.{ts,tsx}';
|
||||||
@@ -25,13 +26,10 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-feature-settings:
|
font-feature-settings: 'rlig' 1, 'calt' 1;
|
||||||
'rlig' 1,
|
|
||||||
'calt' 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html, body {
|
||||||
body {
|
|
||||||
scroll-padding-top: 56px;
|
scroll-padding-top: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,4 +49,125 @@
|
|||||||
textarea::placeholder {
|
textarea::placeholder {
|
||||||
color: theme(--color-muted-foreground);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,92 +1 @@
|
|||||||
/*
|
/* theme.css — kept for backward compatibility, all theme tokens now in globals.css */
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
|
|||||||
448
apps/web/supabase/migrations/20260401000001_cms_foundation.sql
Normal file
448
apps/web/supabase/migrations/20260401000001_cms_foundation.sql
Normal 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))
|
||||||
|
);
|
||||||
|
|
||||||
@@ -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;
|
||||||
482
apps/web/supabase/migrations/20260402000001_module_engine.sql
Normal file
482
apps/web/supabase/migrations/20260402000001_module_engine.sql
Normal 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;
|
||||||
@@ -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));
|
||||||
@@ -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)));
|
||||||
@@ -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();
|
||||||
@@ -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)));
|
||||||
118
apps/web/supabase/migrations/20260407000001_finance_sepa.sql
Normal file
118
apps/web/supabase/migrations/20260407000001_finance_sepa.sql
Normal 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)));
|
||||||
70
apps/web/supabase/migrations/20260408000001_newsletter.sql
Normal file
70
apps/web/supabase/migrations/20260408000001_newsletter.sql
Normal 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)));
|
||||||
@@ -36,11 +36,17 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@manypkg/cli": "catalog:",
|
"@manypkg/cli": "catalog:",
|
||||||
|
"@tiptap/pm": "catalog:",
|
||||||
|
"@tiptap/react": "catalog:",
|
||||||
|
"@tiptap/starter-kit": "catalog:",
|
||||||
"@turbo/gen": "catalog:",
|
"@turbo/gen": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"cross-env": "catalog:",
|
"cross-env": "catalog:",
|
||||||
|
"exceljs": "catalog:",
|
||||||
|
"iban": "catalog:",
|
||||||
"oxfmt": "catalog:",
|
"oxfmt": "catalog:",
|
||||||
"oxlint": "catalog:",
|
"oxlint": "catalog:",
|
||||||
|
"papaparse": "catalog:",
|
||||||
"server-only": "catalog:",
|
"server-only": "catalog:",
|
||||||
"turbo": "catalog:",
|
"turbo": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
34
packages/features/booking-management/package.json
Normal file
34
packages/features/booking-management/package.json
Normal 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:"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -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'),
|
||||||
|
});
|
||||||
88
packages/features/booking-management/src/server/api.ts
Normal file
88
packages/features/booking-management/src/server/api.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
6
packages/features/booking-management/tsconfig.json
Normal file
6
packages/features/booking-management/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "@kit/tsconfig/base.json",
|
||||||
|
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
|
||||||
|
"include": ["*.ts", "*.tsx", "src"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
34
packages/features/course-management/package.json
Normal file
34
packages/features/course-management/package.json
Normal 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:"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user