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 { PageBody } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
|
||||
import { DashboardDemo } from './_components/dashboard-demo';
|
||||
import { TeamAccountLayoutPageHeader } from './_components/team-account-layout-page-header';
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
import { createFinanceApi } from '@kit/finance/api';
|
||||
import { createNewsletterApi } from '@kit/newsletter/api';
|
||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
import { createEventManagementApi } from '@kit/event-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
|
||||
interface TeamAccountHomePageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const t = await getTranslations('teams');
|
||||
const title = t('home.pageTitle');
|
||||
export default async function TeamAccountHomePage({
|
||||
params,
|
||||
}: TeamAccountHomePageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
return {
|
||||
title,
|
||||
};
|
||||
};
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id, name')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
function TeamAccountHomePage({ params }: TeamAccountHomePageProps) {
|
||||
const account = use(params).account;
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
// Load all stats in parallel with allSettled for resilience
|
||||
const [
|
||||
memberStatsResult,
|
||||
courseStatsResult,
|
||||
invoicesResult,
|
||||
newslettersResult,
|
||||
bookingsResult,
|
||||
eventsResult,
|
||||
] = await Promise.allSettled([
|
||||
createMemberManagementApi(client).getMemberStatistics(acct.id),
|
||||
createCourseManagementApi(client).getStatistics(acct.id),
|
||||
createFinanceApi(client).listInvoices(acct.id, { status: 'draft' }),
|
||||
createNewsletterApi(client).listNewsletters(acct.id),
|
||||
createBookingManagementApi(client).listBookings(acct.id, { page: 1 }),
|
||||
createEventManagementApi(client).listEvents(acct.id, { page: 1 }),
|
||||
]);
|
||||
|
||||
const memberStats =
|
||||
memberStatsResult.status === 'fulfilled'
|
||||
? memberStatsResult.value
|
||||
: { total: 0, active: 0, inactive: 0, pending: 0, resigned: 0 };
|
||||
|
||||
const courseStats =
|
||||
courseStatsResult.status === 'fulfilled'
|
||||
? courseStatsResult.value
|
||||
: { totalCourses: 0, openCourses: 0, completedCourses: 0, totalParticipants: 0 };
|
||||
|
||||
const openInvoices =
|
||||
invoicesResult.status === 'fulfilled' ? invoicesResult.value : [];
|
||||
|
||||
const newsletters =
|
||||
newslettersResult.status === 'fulfilled' ? newslettersResult.value : [];
|
||||
|
||||
const bookings =
|
||||
bookingsResult.status === 'fulfilled'
|
||||
? bookingsResult.value
|
||||
: { data: [], total: 0 };
|
||||
|
||||
const events =
|
||||
eventsResult.status === 'fulfilled'
|
||||
? eventsResult.value
|
||||
: { data: [], total: 0 };
|
||||
|
||||
const accountName = acct.name ? String(acct.name) : 'Dashboard';
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<TeamAccountLayoutPageHeader
|
||||
account={account}
|
||||
title={<Trans i18nKey={'common.routes.dashboard'} />}
|
||||
description={<AppBreadcrumbs />}
|
||||
<CmsPageShell account={account} title={accountName}>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title="Mitglieder"
|
||||
value={memberStats.active}
|
||||
icon={<UserCheck className="h-5 w-5" />}
|
||||
description={`${memberStats.total} gesamt, ${memberStats.pending} ausstehend`}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Kurse"
|
||||
value={courseStats.openCourses}
|
||||
icon={<GraduationCap className="h-5 w-5" />}
|
||||
description={`${courseStats.totalCourses} gesamt, ${courseStats.totalParticipants} Teilnehmer`}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Offene Rechnungen"
|
||||
value={openInvoices.length}
|
||||
icon={<FileText className="h-5 w-5" />}
|
||||
description="Entwürfe zum Versenden"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Newsletter"
|
||||
value={newsletters.length}
|
||||
icon={<Mail className="h-5 w-5" />}
|
||||
description="Erstellt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DashboardDemo />
|
||||
</PageBody>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Letzte Aktivität */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Letzte Aktivität
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Aktuelle Buchungen und Veranstaltungen
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Recent bookings */}
|
||||
{bookings.data.slice(0, 3).map((booking: Record<string, unknown>) => (
|
||||
<div
|
||||
key={String(booking.id)}
|
||||
className="flex items-center justify-between rounded-md border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-full bg-blue-500/10 p-2 text-blue-600">
|
||||
<BedDouble className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
href={`/home/${account}/bookings/${String(booking.id)}`}
|
||||
className="text-sm font-medium hover:underline"
|
||||
>
|
||||
Buchung #{String(booking.id).slice(0, 8)}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{booking.check_in
|
||||
? new Date(
|
||||
String(booking.check_in),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}{' '}
|
||||
–{' '}
|
||||
{booking.check_out
|
||||
? new Date(
|
||||
String(booking.check_out),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{String(booking.status ?? '—')}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Recent events */}
|
||||
{events.data.slice(0, 3).map((event: Record<string, unknown>) => (
|
||||
<div
|
||||
key={String(event.id)}
|
||||
className="flex items-center justify-between rounded-md border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-full bg-amber-500/10 p-2 text-amber-600">
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
href={`/home/${account}/events/${String(event.id)}`}
|
||||
className="text-sm font-medium hover:underline"
|
||||
>
|
||||
{String(event.name)}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{event.event_date
|
||||
? new Date(
|
||||
String(event.event_date),
|
||||
).toLocaleDateString('de-DE')
|
||||
: 'Kein Datum'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{String(event.status ?? '—')}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{bookings.data.length === 0 && events.data.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Activity className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Noch keine Aktivitäten vorhanden
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Schnellaktionen */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Schnellaktionen</CardTitle>
|
||||
<CardDescription>Häufig verwendete Aktionen</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<Link href={`/home/${account}/members-cms/new`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Neues Mitglied
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={`/home/${account}/courses/new`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<GraduationCap className="h-4 w-4" />
|
||||
Neuer Kurs
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={`/home/${account}/newsletter/new`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
Newsletter erstellen
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={`/home/${account}/bookings/new`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<BedDouble className="h-4 w-4" />
|
||||
Neue Buchung
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={`/home/${account}/events/new`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Neue Veranstaltung
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Module Overview Row */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Buchungen
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{bookings.total}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{bookings.data.filter(
|
||||
(b: Record<string, unknown>) =>
|
||||
b.status === 'confirmed' || b.status === 'checked_in',
|
||||
).length}{' '}
|
||||
aktiv
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/home/${account}/bookings`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Veranstaltungen
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{events.total}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{events.data.filter(
|
||||
(e: Record<string, unknown>) =>
|
||||
e.status === 'published' ||
|
||||
e.status === 'registration_open',
|
||||
).length}{' '}
|
||||
aktiv
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/home/${account}/events`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Kurse abgeschlossen
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{courseStats.completedCourses}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
von {courseStats.totalCourses} insgesamt
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/home/${account}/courses`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamAccountHomePage;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import '../styles/globals.css';
|
||||
import { Noto_Serif } from "next/font/google";
|
||||
|
||||
const notoSerif = Noto_Serif({subsets:['latin'],variable:'--font-serif'});
|
||||
|
||||
|
||||
export default function RootLayout({ children }: React.PropsWithChildren) {
|
||||
return children;
|
||||
|
||||
25
apps/web/components.json
Normal file
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({
|
||||
error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY',
|
||||
}),
|
||||
// CMS feature flags
|
||||
enableModuleBuilder: z.boolean().default(true),
|
||||
enableMemberManagement: z.boolean().default(true),
|
||||
enableCourseManagement: z.boolean().default(true),
|
||||
enableBookingManagement: z.boolean().default(false),
|
||||
enableSepaPayments: z.boolean().default(true),
|
||||
enableDocumentGeneration: z.boolean().default(true),
|
||||
enableNewsletter: z.boolean().default(true),
|
||||
enableGdprCompliance: z.boolean().default(true),
|
||||
});
|
||||
|
||||
const featuresFlagConfig = FeatureFlagsSchema.parse({
|
||||
@@ -90,6 +99,39 @@ const featuresFlagConfig = FeatureFlagsSchema.parse({
|
||||
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY,
|
||||
false,
|
||||
),
|
||||
// CMS feature flags
|
||||
enableModuleBuilder: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_MODULE_BUILDER,
|
||||
true,
|
||||
),
|
||||
enableMemberManagement: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_MEMBER_MANAGEMENT,
|
||||
true,
|
||||
),
|
||||
enableCourseManagement: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_COURSE_MANAGEMENT,
|
||||
true,
|
||||
),
|
||||
enableBookingManagement: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_BOOKING_MANAGEMENT,
|
||||
false,
|
||||
),
|
||||
enableSepaPayments: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_SEPA_PAYMENTS,
|
||||
true,
|
||||
),
|
||||
enableDocumentGeneration: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_DOCUMENT_GENERATION,
|
||||
true,
|
||||
),
|
||||
enableNewsletter: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_NEWSLETTER,
|
||||
true,
|
||||
),
|
||||
enableGdprCompliance: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_GDPR_COMPLIANCE,
|
||||
true,
|
||||
),
|
||||
} satisfies z.output<typeof FeatureFlagsSchema>);
|
||||
|
||||
export default featuresFlagConfig;
|
||||
|
||||
@@ -22,6 +22,14 @@ const PathsSchema = z.object({
|
||||
accountProfileSettings: z.string().min(1),
|
||||
createTeam: z.string().min(1),
|
||||
joinTeam: z.string().min(1),
|
||||
// CMS paths
|
||||
accountModules: z.string().min(1),
|
||||
accountCmsMembers: z.string().min(1),
|
||||
accountCourses: z.string().min(1),
|
||||
accountBookings: z.string().min(1),
|
||||
accountFinance: z.string().min(1),
|
||||
accountDocuments: z.string().min(1),
|
||||
accountNewsletter: z.string().min(1),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -47,6 +55,14 @@ const pathsConfig = PathsSchema.parse({
|
||||
accountProfileSettings: `/home/[account]/settings/profile`,
|
||||
createTeam: '/home/create-team',
|
||||
joinTeam: '/join',
|
||||
// CMS paths
|
||||
accountModules: `/home/[account]/modules`,
|
||||
accountCmsMembers: `/home/[account]/members-cms`,
|
||||
accountCourses: `/home/[account]/courses`,
|
||||
accountBookings: `/home/[account]/bookings`,
|
||||
accountFinance: `/home/[account]/finance`,
|
||||
accountDocuments: `/home/[account]/documents`,
|
||||
accountNewsletter: `/home/[account]/newsletter`,
|
||||
},
|
||||
} satisfies z.output<typeof PathsSchema>);
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { CreditCard, LayoutDashboard, Settings, Users } from 'lucide-react';
|
||||
import {
|
||||
CreditCard, LayoutDashboard, Settings, Users, Database,
|
||||
UserCheck, GraduationCap, Hotel, Calendar, Wallet,
|
||||
FileText, Mail,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
|
||||
|
||||
@@ -17,7 +21,61 @@ const getRoutes = (account: string) => [
|
||||
Icon: <LayoutDashboard className={iconClasses} />,
|
||||
highlightMatch: `${pathsConfig.app.home}$`,
|
||||
},
|
||||
],
|
||||
featureFlagsConfig.enableModuleBuilder
|
||||
? {
|
||||
label: 'common.routes.modules',
|
||||
path: createPath(pathsConfig.app.accountModules, account),
|
||||
Icon: <Database className={iconClasses} />,
|
||||
}
|
||||
: undefined,
|
||||
featureFlagsConfig.enableMemberManagement
|
||||
? {
|
||||
label: 'common.routes.cmsMembers',
|
||||
path: createPath(pathsConfig.app.accountCmsMembers, account),
|
||||
Icon: <UserCheck className={iconClasses} />,
|
||||
}
|
||||
: undefined,
|
||||
featureFlagsConfig.enableCourseManagement
|
||||
? {
|
||||
label: 'common.routes.courses',
|
||||
path: createPath(pathsConfig.app.accountCourses, account),
|
||||
Icon: <GraduationCap className={iconClasses} />,
|
||||
}
|
||||
: undefined,
|
||||
featureFlagsConfig.enableBookingManagement
|
||||
? {
|
||||
label: 'common.routes.bookings',
|
||||
path: createPath(pathsConfig.app.accountBookings, account),
|
||||
Icon: <Hotel className={iconClasses} />,
|
||||
}
|
||||
: undefined,
|
||||
{
|
||||
label: 'common.routes.events',
|
||||
path: createPath(`/home/[account]/events`, account),
|
||||
Icon: <Calendar className={iconClasses} />,
|
||||
},
|
||||
featureFlagsConfig.enableSepaPayments
|
||||
? {
|
||||
label: 'common.routes.finance',
|
||||
path: createPath(pathsConfig.app.accountFinance, account),
|
||||
Icon: <Wallet className={iconClasses} />,
|
||||
}
|
||||
: undefined,
|
||||
featureFlagsConfig.enableDocumentGeneration
|
||||
? {
|
||||
label: 'common.routes.documents',
|
||||
path: createPath(pathsConfig.app.accountDocuments, account),
|
||||
Icon: <FileText className={iconClasses} />,
|
||||
}
|
||||
: undefined,
|
||||
featureFlagsConfig.enableNewsletter
|
||||
? {
|
||||
label: 'common.routes.newsletter',
|
||||
path: createPath(pathsConfig.app.accountNewsletter, account),
|
||||
Icon: <Mail className={iconClasses} />,
|
||||
}
|
||||
: undefined,
|
||||
].filter(Boolean),
|
||||
},
|
||||
{
|
||||
label: 'common.routes.settings',
|
||||
|
||||
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",
|
||||
"settings": "Settings",
|
||||
"profile": "Profile",
|
||||
"application": "Application"
|
||||
"application": "Application",
|
||||
"modules": "Modules",
|
||||
"cmsMembers": "Members",
|
||||
"courses": "Courses",
|
||||
"bookings": "Bookings",
|
||||
"events": "Events",
|
||||
"finance": "Finance",
|
||||
"documents": "Documents",
|
||||
"newsletter": "Newsletter"
|
||||
},
|
||||
"roles": {
|
||||
"owner": {
|
||||
|
||||
@@ -19,6 +19,7 @@ const namespaces = [
|
||||
'teams',
|
||||
'billing',
|
||||
'marketing',
|
||||
'cms',
|
||||
] as const;
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "catalog:",
|
||||
"@hookform/resolvers": "catalog:",
|
||||
"@kit/accounts": "workspace:*",
|
||||
"@kit/admin": "workspace:*",
|
||||
@@ -35,12 +36,20 @@
|
||||
"@kit/auth": "workspace:*",
|
||||
"@kit/billing": "workspace:*",
|
||||
"@kit/billing-gateway": "workspace:*",
|
||||
"@kit/booking-management": "workspace:*",
|
||||
"@kit/cms": "workspace:*",
|
||||
"@kit/course-management": "workspace:*",
|
||||
"@kit/database-webhooks": "workspace:*",
|
||||
"@kit/document-generator": "workspace:*",
|
||||
"@kit/email-templates": "workspace:*",
|
||||
"@kit/event-management": "workspace:*",
|
||||
"@kit/finance": "workspace:*",
|
||||
"@kit/i18n": "workspace:*",
|
||||
"@kit/mailers": "workspace:*",
|
||||
"@kit/member-management": "workspace:*",
|
||||
"@kit/module-builder": "workspace:*",
|
||||
"@kit/monitoring": "workspace:*",
|
||||
"@kit/newsletter": "workspace:*",
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/notifications": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
@@ -54,6 +63,8 @@
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-table": "catalog:",
|
||||
"class-variance-authority": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"date-fns": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"next": "catalog:",
|
||||
@@ -66,6 +77,7 @@
|
||||
"react-dom": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"recharts": "catalog:",
|
||||
"shadcn": "^4.1.1",
|
||||
"tailwind-merge": "catalog:",
|
||||
"tw-animate-css": "catalog:",
|
||||
"urlpattern-polyfill": "catalog:",
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
/*
|
||||
* global.css
|
||||
*
|
||||
* Global styles for the entire application
|
||||
* globals.css — Main CSS entry point
|
||||
* Theme: shadcn preset b1G3f4DGS (Nova + Inter)
|
||||
*/
|
||||
|
||||
/* Tailwind CSS */
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@import 'shadcn/tailwind.css';
|
||||
|
||||
/* local styles - update the below if you add a new style */
|
||||
@import './theme.css';
|
||||
/* local styles */
|
||||
@import './shadcn-ui.css';
|
||||
@import './markdoc.css';
|
||||
@import './makerkit.css';
|
||||
|
||||
/* content sources - update the below if you add a new path */
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* content sources */
|
||||
@source '../../../packages/*/src/**/*.{ts,tsx}';
|
||||
@source '../../../packages/features/*/src/**/*.{ts,tsx}';
|
||||
@source '../../../packages/billing/*/src/**/*.{ts,tsx}';
|
||||
@@ -25,13 +26,10 @@
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings:
|
||||
'rlig' 1,
|
||||
'calt' 1;
|
||||
font-feature-settings: 'rlig' 1, 'calt' 1;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
html, body {
|
||||
scroll-padding-top: 56px;
|
||||
}
|
||||
|
||||
@@ -51,4 +49,125 @@
|
||||
textarea::placeholder {
|
||||
color: theme(--color-muted-foreground);
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
}
|
||||
|
||||
@utility container {
|
||||
@apply mx-auto px-4 lg:px-8 xl:max-w-[80rem];
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-serif: var(--font-sans);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
/* Theme: Preset b1G3f4DGS — teal primary, warm neutral base */
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.153 0.006 107.1);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.153 0.006 107.1);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.153 0.006 107.1);
|
||||
--primary: oklch(0.52 0.105 223.128);
|
||||
--primary-foreground: oklch(0.984 0.019 200.873);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.966 0.005 106.5);
|
||||
--muted-foreground: oklch(0.58 0.031 107.3);
|
||||
--accent: oklch(0.966 0.005 106.5);
|
||||
--accent-foreground: oklch(0.228 0.013 107.4);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.93 0.007 106.5);
|
||||
--input: oklch(0.93 0.007 106.5);
|
||||
--ring: oklch(0.737 0.021 106.9);
|
||||
--chart-1: oklch(0.879 0.169 91.605);
|
||||
--chart-2: oklch(0.769 0.188 70.08);
|
||||
--chart-3: oklch(0.666 0.179 58.318);
|
||||
--chart-4: oklch(0.555 0.163 48.998);
|
||||
--chart-5: oklch(0.473 0.137 46.201);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.988 0.003 106.5);
|
||||
--sidebar-foreground: oklch(0.153 0.006 107.1);
|
||||
--sidebar-primary: oklch(0.609 0.126 221.723);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.019 200.873);
|
||||
--sidebar-accent: oklch(0.966 0.005 106.5);
|
||||
--sidebar-accent-foreground: oklch(0.228 0.013 107.4);
|
||||
--sidebar-border: oklch(0.93 0.007 106.5);
|
||||
--sidebar-ring: oklch(0.737 0.021 106.9);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.153 0.006 107.1);
|
||||
--foreground: oklch(0.988 0.003 106.5);
|
||||
--card: oklch(0.228 0.013 107.4);
|
||||
--card-foreground: oklch(0.988 0.003 106.5);
|
||||
--popover: oklch(0.228 0.013 107.4);
|
||||
--popover-foreground: oklch(0.988 0.003 106.5);
|
||||
--primary: oklch(0.45 0.085 224.283);
|
||||
--primary-foreground: oklch(0.984 0.019 200.873);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.286 0.016 107.4);
|
||||
--muted-foreground: oklch(0.737 0.021 106.9);
|
||||
--accent: oklch(0.286 0.016 107.4);
|
||||
--accent-foreground: oklch(0.988 0.003 106.5);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.58 0.031 107.3);
|
||||
--chart-1: oklch(0.879 0.169 91.605);
|
||||
--chart-2: oklch(0.769 0.188 70.08);
|
||||
--chart-3: oklch(0.666 0.179 58.318);
|
||||
--chart-4: oklch(0.555 0.163 48.998);
|
||||
--chart-5: oklch(0.473 0.137 46.201);
|
||||
--sidebar: oklch(0.228 0.013 107.4);
|
||||
--sidebar-foreground: oklch(0.988 0.003 106.5);
|
||||
--sidebar-primary: oklch(0.715 0.143 215.221);
|
||||
--sidebar-primary-foreground: oklch(0.302 0.056 229.695);
|
||||
--sidebar-accent: oklch(0.286 0.016 107.4);
|
||||
--sidebar-accent-foreground: oklch(0.988 0.003 106.5);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.58 0.031 107.3);
|
||||
}
|
||||
|
||||
@@ -1,92 +1 @@
|
||||
/*
|
||||
* theme.css
|
||||
*
|
||||
* Theme styles for the entire application
|
||||
*/
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@utility container {
|
||||
@apply mx-auto px-4 lg:px-8 xl:max-w-[80rem];
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Theme variables
|
||||
*/
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.58 0.22 27);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.16 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.16 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.87 0 0);
|
||||
--primary-foreground: oklch(0.16 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.371 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--sidebar: oklch(0.16 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
/* theme.css — kept for backward compatibility, all theme tokens now in globals.css */
|
||||
|
||||
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": {
|
||||
"@manypkg/cli": "catalog:",
|
||||
"@tiptap/pm": "catalog:",
|
||||
"@tiptap/react": "catalog:",
|
||||
"@tiptap/starter-kit": "catalog:",
|
||||
"@turbo/gen": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"cross-env": "catalog:",
|
||||
"exceljs": "catalog:",
|
||||
"iban": "catalog:",
|
||||
"oxfmt": "catalog:",
|
||||
"oxlint": "catalog:",
|
||||
"papaparse": "catalog:",
|
||||
"server-only": "catalog:",
|
||||
"turbo": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
|
||||
@@ -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