Compare commits

...

13 Commits

Author SHA1 Message Date
T. Zehetbauer
f82a366a52 Merge remote-tracking branch 'origin/main'
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m51s
Workflow / ⚫️ Test (push) Has been skipped
2026-04-01 14:05:13 +02:00
T. Zehetbauer
5e1976f07b Merge remote-tracking branch 'origin/main' 2026-04-01 13:48:10 +02:00
T. Zehetbauer
a5baaae12f Merge remote-tracking branch 'origin/main'
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m52s
Workflow / ⚫️ Test (push) Has been skipped
2026-04-01 13:33:58 +02:00
T. Zehetbauer
c98cada7f6 refactor: improve code readability and consistency in api.ts and common.json 2026-04-01 13:33:43 +02:00
T. Zehetbauer
2a9d543ee4 Merge remote-tracking branch 'origin/main'
Some checks failed
Workflow / ⚫️ Test (push) Has been cancelled
Workflow / ʦ TypeScript (push) Has been cancelled
# Conflicts:
#	docker-compose.yml
2026-04-01 13:30:00 +02:00
T. Zehetbauer
98afe6aa5f feat: enhance accessibility and testing with data-test attributes and improve error handling
Some checks failed
Workflow / ⚫️ Test (push) Has been cancelled
Workflow / ʦ TypeScript (push) Has been cancelled
2026-04-01 13:22:23 +02:00
T. Zehetbauer
da8a43a3b0 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	apps/web/app/[locale]/home/[account]/_components/team-account-layout-mobile-navigation.tsx
#	packages/features/course-management/src/server/api.ts
#	packages/features/event-management/src/server/api.ts
#	packages/supabase/src/get-supabase-client-keys.ts
#	pnpm-lock.yaml
2026-04-01 13:22:17 +02:00
T. Zehetbauer
abac22feb1 feat: enhance accessibility and testing with data-test attributes and improve error handling
Some checks failed
Workflow / ⚫️ Test (push) Has been cancelled
Workflow / ʦ TypeScript (push) Has been cancelled
2026-04-01 10:46:44 +02:00
T. Zehetbauer
3bcc5c70a3 feat: add data-test attributes for improved testing in various components 2026-04-01 10:23:35 +02:00
T. Zehetbauer
fd8c2cc32a feat: add cross-organization member search and template cloning functionality 2026-04-01 10:15:35 +02:00
T. Zehetbauer
d3db316a68 Merge remote-tracking branch 'origin/main' 2026-04-01 09:31:25 +02:00
T. Zehetbauer
2f80d5cc4a Merge remote-tracking branch 'origin/main'
# Conflicts:
#	docker-compose.yml
2026-03-31 22:19:42 +02:00
T. Zehetbauer
59546ad6d2 Add account hierarchy framework with migrations, RLS policies, and UI components 2026-03-31 22:18:04 +02:00
305 changed files with 23955 additions and 4100 deletions

View File

@@ -44,6 +44,12 @@ ADDITIONAL_REDIRECT_URLS=
# --- Webhooks --- # --- Webhooks ---
DB_WEBHOOK_SECRET=your-webhook-secret DB_WEBHOOK_SECRET=your-webhook-secret
# --- Monitoring (Sentry) ---
NEXT_PUBLIC_MONITORING_PROVIDER=sentry
NEXT_PUBLIC_SENTRY_DSN=https://your-dsn@o123456.ingest.sentry.io/123456
# NEXT_PUBLIC_SENTRY_ENVIRONMENT=production
# SENTRY_AUTH_TOKEN=your-auth-token-for-source-maps
# --- Feature Flags --- # --- Feature Flags ---
# All default to true, set to false to disable # All default to true, set to false to disable
# NEXT_PUBLIC_ENABLE_MODULE_BUILDER=true # NEXT_PUBLIC_ENABLE_MODULE_BUILDER=true

View File

@@ -4,7 +4,9 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test.describe('Course Management', () => { test.describe('Course Management', () => {
test('create course, enroll participant, check capacity, waitlist', async ({ page }) => { test('create course, enroll participant, check capacity, waitlist', async ({
page,
}) => {
// Create course with capacity 2 // Create course with capacity 2
// Enroll participant 1 → status: enrolled // Enroll participant 1 → status: enrolled
// Enroll participant 2 → status: enrolled // Enroll participant 2 → status: enrolled

View File

@@ -15,7 +15,9 @@ test.describe('Member Management', () => {
await expect(page.locator('h1')).toContainText('Mitglieder'); await expect(page.locator('h1')).toContainText('Mitglieder');
}); });
test('application workflow: submit → review → approve → member created', async ({ page }) => { test('application workflow: submit → review → approve → member created', async ({
page,
}) => {
// Submit application // Submit application
// Review application // Review application
// Approve → verify member auto-created // Approve → verify member auto-created

View File

@@ -4,7 +4,9 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test.describe('Module Builder', () => { test.describe('Module Builder', () => {
test('create module, add fields, insert record, query, update, soft-delete', async ({ page }) => { test('create module, add fields, insert record, query, update, soft-delete', async ({
page,
}) => {
// Login // Login
await page.goto('/auth/sign-in'); await page.goto('/auth/sign-in');
await page.fill('input[name="email"]', 'test@example.com'); await page.fill('input[name="email"]', 'test@example.com');

View File

@@ -4,7 +4,9 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test.describe('Newsletter', () => { test.describe('Newsletter', () => {
test('create campaign, select recipients from members, preview, send', async ({ page }) => { test('create campaign, select recipients from members, preview, send', async ({
page,
}) => {
// Create newsletter // Create newsletter
// Add recipients from member filter (status=active, hasEmail=true) // Add recipients from member filter (status=active, hasEmail=true)
// Preview with variable substitution // Preview with variable substitution

View File

@@ -4,7 +4,9 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test.describe('SEPA / Finance', () => { test.describe('SEPA / Finance', () => {
test('create SEPA direct debit batch, add items, generate XML', async ({ page }) => { test('create SEPA direct debit batch, add items, generate XML', async ({
page,
}) => {
// Create batch // Create batch
// Add items with valid IBANs // Add items with valid IBANs
// Generate XML // Generate XML

View File

@@ -78,7 +78,7 @@ function Home() {
{/* Trust Indicators */} {/* Trust Indicators */}
<div className={'container mx-auto'}> <div className={'container mx-auto'}>
<div className="flex flex-col items-center gap-8"> <div className="flex flex-col items-center gap-8">
<p className="text-muted-foreground text-sm font-medium uppercase tracking-widest"> <p className="text-muted-foreground text-sm font-medium tracking-widest uppercase">
<Trans i18nKey={'marketing.trustedBy'} /> <Trans i18nKey={'marketing.trustedBy'} />
</p> </p>
@@ -89,10 +89,7 @@ function Home() {
label="marketing.trustSchools" label="marketing.trustSchools"
/> />
<TrustItem icon={BookOpenIcon} label="marketing.trustClubs" /> <TrustItem icon={BookOpenIcon} label="marketing.trustClubs" />
<TrustItem <TrustItem icon={GlobeIcon} label="marketing.trustOrganizations" />
icon={GlobeIcon}
label="marketing.trustOrganizations"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -184,9 +181,7 @@ function Home() {
</b> </b>
.{' '} .{' '}
<span className="text-secondary-foreground/70 block font-normal tracking-tight"> <span className="text-secondary-foreground/70 block font-normal tracking-tight">
<Trans <Trans i18nKey={'marketing.additionalFeaturesSubheading'} />
i18nKey={'marketing.additionalFeaturesSubheading'}
/>
</span> </span>
</> </>
} }
@@ -256,7 +251,7 @@ function Home() {
<div className="container mx-auto"> <div className="container mx-auto">
<div className="flex flex-col items-center gap-12"> <div className="flex flex-col items-center gap-12">
<div className="text-center"> <div className="text-center">
<h2 className="text-3xl font-medium tracking-tight dark:text-white xl:text-5xl"> <h2 className="text-3xl font-medium tracking-tight xl:text-5xl dark:text-white">
<Trans i18nKey={'marketing.howItWorksHeading'} /> <Trans i18nKey={'marketing.howItWorksHeading'} />
</h2> </h2>
<p className="text-secondary-foreground/70 mx-auto mt-4 max-w-2xl text-xl font-medium tracking-tight"> <p className="text-secondary-foreground/70 mx-auto mt-4 max-w-2xl text-xl font-medium tracking-tight">
@@ -316,7 +311,7 @@ function Home() {
{/* Final CTA */} {/* Final CTA */}
<div className="container mx-auto"> <div className="container mx-auto">
<div className="bg-primary/5 flex flex-col items-center gap-8 rounded-2xl border p-12 text-center lg:p-16"> <div className="bg-primary/5 flex flex-col items-center gap-8 rounded-2xl border p-12 text-center lg:p-16">
<h2 className="max-w-3xl text-3xl font-medium tracking-tight dark:text-white xl:text-5xl"> <h2 className="max-w-3xl text-3xl font-medium tracking-tight xl:text-5xl dark:text-white">
<Trans i18nKey={'marketing.ctaHeading'} /> <Trans i18nKey={'marketing.ctaHeading'} />
</h2> </h2>
<p className="text-secondary-foreground/70 max-w-2xl text-lg"> <p className="text-secondary-foreground/70 max-w-2xl text-lg">

View File

@@ -9,10 +9,9 @@ export default async function AdminAuditPage() {
</div> </div>
<div className="rounded-lg border p-6"> <div className="rounded-lg border p-6">
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
Alle Datenänderungen (Erstellen, Ändern, Löschen, Sperren) Alle Datenänderungen (Erstellen, Ändern, Löschen, Sperren) über alle
über alle Mandanten hinweg. Filtert nach Zeitraum, Benutzer, Mandanten hinweg. Filtert nach Zeitraum, Benutzer, Tabelle und Aktion.
Tabelle und Aktion.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -9,10 +9,11 @@ export default async function AdminGdprPage() {
</div> </div>
<div className="rounded-lg border p-6"> <div className="rounded-lg border p-6">
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
Mandantenübergreifende Übersicht aller registrierten Verarbeitungstätigkeiten Mandantenübergreifende Übersicht aller registrierten
gemäß Art. 30 DSGVO. Umfasst Zweck, Rechtsgrundlage, Datenkategorien, Verarbeitungstätigkeiten gemäß Art. 30 DSGVO. Umfasst Zweck,
Aufbewahrungsfristen und technisch-organisatorische Maßnahmen. Rechtsgrundlage, Datenkategorien, Aufbewahrungsfristen und
technisch-organisatorische Maßnahmen.
</p> </p>
</div> </div>
</div> </div>

View File

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

View File

@@ -9,9 +9,10 @@ export default async function AdminModulesPage() {
</div> </div>
<div className="rounded-lg border p-6"> <div className="rounded-lg border p-6">
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
Hier werden alle Module über alle Mandanten hinweg angezeigt. Hier werden alle Module über alle Mandanten hinweg angezeigt.
Ermöglicht die zentrale Verwaltung von Modulvorlagen und -konfigurationen. Ermöglicht die zentrale Verwaltung von Modulvorlagen und
-konfigurationen.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,13 @@
import { createClient } from '@supabase/supabase-js';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { createClient } from '@supabase/supabase-js';
import { SiteRenderer } from '@kit/site-builder/components'; import { SiteRenderer } from '@kit/site-builder/components';
import type { SiteData } from '@kit/site-builder/context'; import type { SiteData } from '@kit/site-builder/context';
interface Props { params: Promise<{ slug: string; page: string[] }> } interface Props {
params: Promise<{ slug: string; page: string[] }>;
}
export default async function ClubSubPage({ params }: Props) { export default async function ClubSubPage({ params }: Props) {
const { slug, page: pagePath } = await params; const { slug, page: pagePath } = await params;
@@ -14,36 +18,73 @@ export default async function ClubSubPage({ params }: Props) {
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!, process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
); );
const { data: account } = await supabase.from('accounts').select('id').eq('slug', slug).single(); const { data: account } = await supabase
.from('accounts')
.select('id')
.eq('slug', slug)
.single();
if (!account) notFound(); if (!account) notFound();
const { data: settings } = await supabase.from('site_settings').select('*').eq('account_id', account.id).eq('is_public', true).maybeSingle(); const { data: settings } = await supabase
.from('site_settings')
.select('*')
.eq('account_id', account.id)
.eq('is_public', true)
.maybeSingle();
if (!settings) notFound(); if (!settings) notFound();
const { data: sitePageData } = await supabase.from('site_pages').select('*') const { data: sitePageData } = await supabase
.eq('account_id', account.id).eq('slug', pageSlug).eq('is_published', true).maybeSingle(); .from('site_pages')
.select('*')
.eq('account_id', account.id)
.eq('slug', pageSlug)
.eq('is_published', true)
.maybeSingle();
if (!sitePageData) notFound(); if (!sitePageData) notFound();
// Pre-fetch CMS data for Puck components // Pre-fetch CMS data for Puck components
const [eventsRes, coursesRes, postsRes] = await Promise.all([ const [eventsRes, coursesRes, postsRes] = await Promise.all([
supabase.from('events').select('id, name, event_date, event_time, location, fee, status') supabase
.eq('account_id', account.id).order('event_date', { ascending: true }).limit(20), .from('events')
supabase.from('courses').select('id, name, start_date, end_date, fee, capacity, status') .select('id, name, event_date, event_time, location, fee, status')
.eq('account_id', account.id).order('start_date', { ascending: true }).limit(20), .eq('account_id', account.id)
supabase.from('cms_posts').select('id, title, excerpt, cover_image, published_at, slug') .order('event_date', { ascending: true })
.eq('account_id', account.id).eq('status', 'published').order('published_at', { ascending: false }).limit(20), .limit(20),
supabase
.from('courses')
.select('id, name, start_date, end_date, fee, capacity, status')
.eq('account_id', account.id)
.order('start_date', { ascending: true })
.limit(20),
supabase
.from('cms_posts')
.select('id, title, excerpt, cover_image, published_at, slug')
.eq('account_id', account.id)
.eq('status', 'published')
.order('published_at', { ascending: false })
.limit(20),
]); ]);
const siteData: SiteData = { const siteData: SiteData = {
accountId: account.id, accountId: account.id,
events: eventsRes.data ?? [], events: eventsRes.data ?? [],
courses: (coursesRes.data ?? []).map(c => ({ ...c, enrolled_count: 0 })), courses: (coursesRes.data ?? []).map((c) => ({ ...c, enrolled_count: 0 })),
posts: postsRes.data ?? [], posts: postsRes.data ?? [],
}; };
return ( return (
<div style={{ '--primary': settings.primary_color, fontFamily: settings.font_family } as React.CSSProperties}> <div
<SiteRenderer data={(sitePageData.puck_data ?? {}) as Record<string, unknown>} siteData={siteData} /> style={
{
'--primary': settings.primary_color,
fontFamily: settings.font_family,
} as React.CSSProperties
}
>
<SiteRenderer
data={(sitePageData.puck_data ?? {}) as Record<string, unknown>}
siteData={siteData}
/>
</div> </div>
); );
} }

View File

@@ -1,23 +1,28 @@
import { Mail } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label'; import { Label } from '@kit/ui/label';
import { Button } from '@kit/ui/button';
import { Mail } from 'lucide-react';
interface Props { params: Promise<{ slug: string }> } interface Props {
params: Promise<{ slug: string }>;
}
export default async function NewsletterSubscribePage({ params }: Props) { export default async function NewsletterSubscribePage({ params }: Props) {
const { slug } = await params; const { slug } = await params;
return ( return (
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6"> <div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader className="text-center"> <CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10"> <div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
<Mail className="h-6 w-6 text-primary" /> <Mail className="text-primary h-6 w-6" />
</div> </div>
<CardTitle>Newsletter abonnieren</CardTitle> <CardTitle>Newsletter abonnieren</CardTitle>
<p className="text-sm text-muted-foreground">Bleiben Sie über Neuigkeiten informiert.</p> <p className="text-muted-foreground text-sm">
Bleiben Sie über Neuigkeiten informiert.
</p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form className="space-y-4"> <form className="space-y-4">
@@ -27,11 +32,19 @@ export default async function NewsletterSubscribePage({ params }: Props) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>E-Mail-Adresse *</Label> <Label>E-Mail-Adresse *</Label>
<Input name="email" type="email" placeholder="ihre@email.de" required /> <Input
name="email"
type="email"
placeholder="ihre@email.de"
required
/>
</div> </div>
<Button type="submit" className="w-full">Abonnieren</Button> <Button type="submit" className="w-full">
<p className="text-xs text-center text-muted-foreground"> Abonnieren
Sie können sich jederzeit abmelden. Wir senden Ihnen eine Bestätigungs-E-Mail. </Button>
<p className="text-muted-foreground text-center text-xs">
Sie können sich jederzeit abmelden. Wir senden Ihnen eine
Bestätigungs-E-Mail.
</p> </p>
</form> </form>
</CardContent> </CardContent>

View File

@@ -1,34 +1,51 @@
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Button } from '@kit/ui/button';
import { MailX } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
interface Props { params: Promise<{ slug: string }>; searchParams: Promise<{ token?: string }> } import { MailX } from 'lucide-react';
export default async function NewsletterUnsubscribePage({ params, searchParams }: Props) { import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
interface Props {
params: Promise<{ slug: string }>;
searchParams: Promise<{ token?: string }>;
}
export default async function NewsletterUnsubscribePage({
params,
searchParams,
}: Props) {
const { slug } = await params; const { slug } = await params;
const { token } = await searchParams; const { token } = await searchParams;
return ( return (
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6"> <div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
<Card className="w-full max-w-md text-center"> <Card className="w-full max-w-md text-center">
<CardHeader> <CardHeader>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10"> <div className="bg-destructive/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
<MailX className="h-6 w-6 text-destructive" /> <MailX className="text-destructive h-6 w-6" />
</div> </div>
<CardTitle>Newsletter abbestellen</CardTitle> <CardTitle>Newsletter abbestellen</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{token ? ( {token ? (
<> <>
<p className="text-sm text-muted-foreground">Möchten Sie den Newsletter wirklich abbestellen?</p> <p className="text-muted-foreground text-sm">
<Button variant="destructive" className="w-full">Abbestellen bestätigen</Button> Möchten Sie den Newsletter wirklich abbestellen?
</p>
<Button variant="destructive" className="w-full">
Abbestellen bestätigen
</Button>
</> </>
) : ( ) : (
<p className="text-sm text-muted-foreground">Kein gültiger Abmeldelink. Bitte verwenden Sie den Link aus der Newsletter-E-Mail.</p> <p className="text-muted-foreground text-sm">
Kein gültiger Abmeldelink. Bitte verwenden Sie den Link aus der
Newsletter-E-Mail.
</p>
)} )}
<Link href={`/club/${slug}`}> <Link href={`/club/${slug}`}>
<Button variant="outline" size="sm"> Zurück zur Website</Button> <Button variant="outline" size="sm">
Zurück zur Website
</Button>
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,9 +1,13 @@
import { createClient } from '@supabase/supabase-js';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { createClient } from '@supabase/supabase-js';
import { SiteRenderer } from '@kit/site-builder/components'; import { SiteRenderer } from '@kit/site-builder/components';
import type { SiteData } from '@kit/site-builder/context'; import type { SiteData } from '@kit/site-builder/context';
interface Props { params: Promise<{ slug: string }> } interface Props {
params: Promise<{ slug: string }>;
}
export default async function ClubHomePage({ params }: Props) { export default async function ClubHomePage({ params }: Props) {
const { slug } = await params; const { slug } = await params;
@@ -13,36 +17,74 @@ export default async function ClubHomePage({ params }: Props) {
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!, process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
); );
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single(); const { data: account } = await supabase
.from('accounts')
.select('id, name')
.eq('slug', slug)
.single();
if (!account) notFound(); if (!account) notFound();
const { data: settings } = await supabase.from('site_settings').select('*').eq('account_id', account.id).eq('is_public', true).maybeSingle(); const { data: settings } = await supabase
.from('site_settings')
.select('*')
.eq('account_id', account.id)
.eq('is_public', true)
.maybeSingle();
if (!settings) notFound(); if (!settings) notFound();
const { data: page } = await supabase.from('site_pages').select('*') const { data: page } = await supabase
.eq('account_id', account.id).eq('is_homepage', true).eq('is_published', true).maybeSingle(); .from('site_pages')
.select('*')
.eq('account_id', account.id)
.eq('is_homepage', true)
.eq('is_published', true)
.maybeSingle();
if (!page) notFound(); if (!page) notFound();
// Pre-fetch CMS data for Puck components // Pre-fetch CMS data for Puck components
const [eventsRes, coursesRes, postsRes] = await Promise.all([ const [eventsRes, coursesRes, postsRes] = await Promise.all([
supabase.from('events').select('id, name, event_date, event_time, location, fee, status') supabase
.eq('account_id', account.id).order('event_date', { ascending: true }).limit(20), .from('events')
supabase.from('courses').select('id, name, start_date, end_date, fee, capacity, status') .select('id, name, event_date, event_time, location, fee, status')
.eq('account_id', account.id).order('start_date', { ascending: true }).limit(20), .eq('account_id', account.id)
supabase.from('cms_posts').select('id, title, excerpt, cover_image, published_at, slug') .order('event_date', { ascending: true })
.eq('account_id', account.id).eq('status', 'published').order('published_at', { ascending: false }).limit(20), .limit(20),
supabase
.from('courses')
.select('id, name, start_date, end_date, fee, capacity, status')
.eq('account_id', account.id)
.order('start_date', { ascending: true })
.limit(20),
supabase
.from('cms_posts')
.select('id, title, excerpt, cover_image, published_at, slug')
.eq('account_id', account.id)
.eq('status', 'published')
.order('published_at', { ascending: false })
.limit(20),
]); ]);
const siteData: SiteData = { const siteData: SiteData = {
accountId: account.id, accountId: account.id,
events: eventsRes.data ?? [], events: eventsRes.data ?? [],
courses: (coursesRes.data ?? []).map(c => ({ ...c, enrolled_count: 0 })), courses: (coursesRes.data ?? []).map((c) => ({ ...c, enrolled_count: 0 })),
posts: postsRes.data ?? [], posts: postsRes.data ?? [],
}; };
return ( return (
<div style={{ '--primary': settings.primary_color, '--secondary': settings.secondary_color, fontFamily: settings.font_family } as React.CSSProperties}> <div
<SiteRenderer data={(page.puck_data ?? {}) as Record<string, unknown>} siteData={siteData} /> style={
{
'--primary': settings.primary_color,
'--secondary': settings.secondary_color,
fontFamily: settings.font_family,
} as React.CSSProperties
}
>
<SiteRenderer
data={(page.puck_data ?? {}) as Record<string, unknown>}
siteData={siteData}
/>
</div> </div>
); );
} }

View File

@@ -1,10 +1,14 @@
import { createClient } from '@supabase/supabase-js';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Button } from '@kit/ui/button';
import { Badge } from '@kit/ui/badge';
import { FileText, Download, Shield, Receipt, FileCheck } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { createClient } from '@supabase/supabase-js';
import { FileText, Download, Shield, Receipt, FileCheck } from 'lucide-react';
import { formatDate } from '@kit/shared/dates';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
interface Props { interface Props {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
} }
@@ -14,77 +18,117 @@ export default async function PortalDocumentsPage({ params }: Props) {
const supabase = createClient( const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!, process.env.SUPABASE_SERVICE_ROLE_KEY ||
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
); );
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single(); const { data: account } = await supabase
if (!account) return <div className="p-8 text-center">Organisation nicht gefunden</div>; .from('accounts')
.select('id, name')
.eq('slug', slug)
.single();
if (!account)
return <div className="p-8 text-center">Organisation nicht gefunden</div>;
// Demo documents (in production: query invoices + cms_files for this member) // Demo documents (in production: query invoices + cms_files for this member)
const documents = [ const documents = [
{ id: '1', title: 'Mitgliedsbeitrag 2026', type: 'Rechnung', date: '2026-01-15', status: 'paid' }, {
{ id: '2', title: 'Mitgliedsbeitrag 2025', type: 'Rechnung', date: '2025-01-10', status: 'paid' }, id: '1',
{ id: '3', title: 'Beitrittserklärung', type: 'Dokument', date: '2020-01-15', status: 'signed' }, title: 'Mitgliedsbeitrag 2026',
type: 'Rechnung',
date: '2026-01-15',
status: 'paid',
},
{
id: '2',
title: 'Mitgliedsbeitrag 2025',
type: 'Rechnung',
date: '2025-01-10',
status: 'paid',
},
{
id: '3',
title: 'Beitrittserklärung',
type: 'Dokument',
date: '2020-01-15',
status: 'signed',
},
]; ];
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {
switch (status) { switch (status) {
case 'paid': return <Badge variant="default">Bezahlt</Badge>; case 'paid':
case 'open': return <Badge variant="secondary">Offen</Badge>; return <Badge variant="default">Bezahlt</Badge>;
case 'signed': return <Badge variant="outline">Unterschrieben</Badge>; case 'open':
default: return <Badge variant="secondary">{status}</Badge>; return <Badge variant="secondary">Offen</Badge>;
case 'signed':
return <Badge variant="outline">Unterschrieben</Badge>;
default:
return <Badge variant="secondary">{status}</Badge>;
} }
}; };
const getIcon = (type: string) => { const getIcon = (type: string) => {
switch (type) { switch (type) {
case 'Rechnung': return <Receipt className="h-5 w-5 text-primary" />; case 'Rechnung':
case 'Dokument': return <FileCheck className="h-5 w-5 text-primary" />; return <Receipt className="text-primary h-5 w-5" />;
default: return <FileText className="h-5 w-5 text-primary" />; case 'Dokument':
return <FileCheck className="text-primary h-5 w-5" />;
default:
return <FileText className="text-primary h-5 w-5" />;
} }
}; };
return ( return (
<div className="min-h-screen bg-muted/30"> <div className="bg-muted/30 min-h-screen">
<header className="border-b bg-background px-6 py-4"> <header className="bg-background border-b px-6 py-4">
<div className="flex items-center justify-between max-w-4xl mx-auto"> <div className="mx-auto flex max-w-4xl items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Shield className="h-5 w-5 text-primary" /> <Shield className="text-primary h-5 w-5" />
<h1 className="text-lg font-bold">Meine Dokumente</h1> <h1 className="text-lg font-bold">Meine Dokumente</h1>
</div> </div>
<Link href={`/club/${slug}/portal`}> <Link href={`/club/${slug}/portal`}>
<Button variant="ghost" size="sm"> Zurück zum Portal</Button> <Button variant="ghost" size="sm">
Zurück zum Portal
</Button>
</Link> </Link>
</div> </div>
</header> </header>
<main className="max-w-3xl mx-auto py-8 px-6"> <main className="mx-auto max-w-3xl px-6 py-8">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Verfügbare Dokumente</CardTitle> <CardTitle>Verfügbare Dokumente</CardTitle>
<p className="text-sm text-muted-foreground">{String(account.name)} Dokumente und Rechnungen</p> <p className="text-muted-foreground text-sm">
{String(account.name)} Dokumente und Rechnungen
</p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{documents.length === 0 ? ( {documents.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <div className="text-muted-foreground py-8 text-center">
<FileText className="mx-auto h-10 w-10 mb-3" /> <FileText className="mx-auto mb-3 h-10 w-10" />
<p>Keine Dokumente vorhanden</p> <p>Keine Dokumente vorhanden</p>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{documents.map((doc) => ( {documents.map((doc) => (
<div key={doc.id} className="flex items-center justify-between rounded-lg border p-4 hover:bg-muted/30 transition-colors"> <div
key={doc.id}
className="hover:bg-muted/30 flex items-center justify-between rounded-lg border p-4 transition-colors"
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{getIcon(doc.type)} {getIcon(doc.type)}
<div> <div>
<p className="font-medium text-sm">{doc.title}</p> <p className="text-sm font-medium">{doc.title}</p>
<p className="text-xs text-muted-foreground">{doc.type} {new Date(doc.date).toLocaleDateString('de-DE')}</p> <p className="text-muted-foreground text-xs">
{doc.type} {formatDate(doc.date)}
</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{getStatusBadge(doc.status)} {getStatusBadge(doc.status)}
<Button size="sm" variant="outline"> <Button size="sm" variant="outline">
<Download className="h-3 w-3 mr-1" /> <Download className="mr-1 h-3 w-3" />
PDF PDF
</Button> </Button>
</div> </div>

View File

@@ -1,18 +1,25 @@
import { createClient } from '@supabase/supabase-js'; import Link from 'next/link';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createClient } from '@supabase/supabase-js';
import { UserPlus, Shield, CheckCircle } from 'lucide-react';
import { formatDate } from '@kit/shared/dates';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label'; import { Label } from '@kit/ui/label';
import { UserPlus, Shield, CheckCircle } from 'lucide-react';
import Link from 'next/link';
interface Props { interface Props {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
searchParams: Promise<{ token?: string }>; searchParams: Promise<{ token?: string }>;
} }
export default async function PortalInvitePage({ params, searchParams }: Props) { export default async function PortalInvitePage({
params,
searchParams,
}: Props) {
const { slug } = await params; const { slug } = await params;
const { token } = await searchParams; const { token } = await searchParams;
@@ -24,28 +31,35 @@ export default async function PortalInvitePage({ params, searchParams }: Props)
); );
// Resolve account // Resolve account
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single(); const { data: account } = await supabase
.from('accounts')
.select('id, name')
.eq('slug', slug)
.single();
if (!account) notFound(); if (!account) notFound();
// Look up invitation // Look up invitation
const { data: invitation } = await supabase.from('member_portal_invitations') const { data: invitation } = await supabase
.from('member_portal_invitations')
.select('id, email, status, expires_at, member_id') .select('id, email, status, expires_at, member_id')
.eq('invite_token', token) .eq('invite_token', token)
.maybeSingle(); .maybeSingle();
if (!invitation || invitation.status !== 'pending') { if (!invitation || invitation.status !== 'pending') {
return ( return (
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6"> <div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
<Card className="max-w-md text-center"> <Card className="max-w-md text-center">
<CardContent className="p-8"> <CardContent className="p-8">
<Shield className="mx-auto h-10 w-10 text-destructive mb-4" /> <Shield className="text-destructive mx-auto mb-4 h-10 w-10" />
<h2 className="text-lg font-bold">Einladung ungültig</h2> <h2 className="text-lg font-bold">Einladung ungültig</h2>
<p className="text-sm text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2 text-sm">
Diese Einladung ist abgelaufen, wurde bereits verwendet oder ist ungültig. Diese Einladung ist abgelaufen, wurde bereits verwendet oder ist
Bitte wenden Sie sich an Ihren Vereinsadministrator. ungültig. Bitte wenden Sie sich an Ihren Vereinsadministrator.
</p> </p>
<Link href={`/club/${slug}`}> <Link href={`/club/${slug}`}>
<Button variant="outline" className="mt-4"> Zur Website</Button> <Button variant="outline" className="mt-4">
Zur Website
</Button>
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
@@ -56,14 +70,14 @@ export default async function PortalInvitePage({ params, searchParams }: Props)
const expired = new Date(invitation.expires_at) < new Date(); const expired = new Date(invitation.expires_at) < new Date();
if (expired) { if (expired) {
return ( return (
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6"> <div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
<Card className="max-w-md text-center"> <Card className="max-w-md text-center">
<CardContent className="p-8"> <CardContent className="p-8">
<Shield className="mx-auto h-10 w-10 text-amber-500 mb-4" /> <Shield className="mx-auto mb-4 h-10 w-10 text-amber-500" />
<h2 className="text-lg font-bold">Einladung abgelaufen</h2> <h2 className="text-lg font-bold">Einladung abgelaufen</h2>
<p className="text-sm text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2 text-sm">
Diese Einladung ist am {new Date(invitation.expires_at).toLocaleDateString('de-DE')} abgelaufen. Diese Einladung ist am {formatDate(invitation.expires_at)}{' '}
Bitte fordern Sie eine neue Einladung an. abgelaufen. Bitte fordern Sie eine neue Einladung an.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -72,41 +86,67 @@ export default async function PortalInvitePage({ params, searchParams }: Props)
} }
return ( return (
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6"> <div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader className="text-center"> <CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10"> <div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
<UserPlus className="h-6 w-6 text-primary" /> <UserPlus className="text-primary h-6 w-6" />
</div> </div>
<CardTitle>Einladung zum Mitgliederbereich</CardTitle> <CardTitle>Einladung zum Mitgliederbereich</CardTitle>
<p className="text-sm text-muted-foreground">{String(account.name)}</p> <p className="text-muted-foreground text-sm">
{String(account.name)}
</p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="rounded-md bg-primary/5 border border-primary/20 p-4 mb-6"> <div className="bg-primary/5 border-primary/20 mb-6 rounded-md border p-4">
<p className="text-sm"> <p className="text-sm">
Sie wurden eingeladen, ein Konto für den Mitgliederbereich zu erstellen. Sie wurden eingeladen, ein Konto für den Mitgliederbereich zu
Damit können Sie Ihr Profil einsehen, Dokumente herunterladen und Ihre Datenschutz-Einstellungen verwalten. erstellen. Damit können Sie Ihr Profil einsehen, Dokumente
herunterladen und Ihre Datenschutz-Einstellungen verwalten.
</p> </p>
</div> </div>
<form className="space-y-4" action={`/api/club/accept-invite`} method="POST"> <form
className="space-y-4"
action={`/api/club/accept-invite`}
method="POST"
>
<input type="hidden" name="token" value={token} /> <input type="hidden" name="token" value={token} />
<input type="hidden" name="slug" value={slug} /> <input type="hidden" name="slug" value={slug} />
<div className="space-y-2"> <div className="space-y-2">
<Label>E-Mail-Adresse</Label> <Label>E-Mail-Adresse</Label>
<Input type="email" value={invitation.email} readOnly className="bg-muted" /> <Input
<p className="text-xs text-muted-foreground">Ihre E-Mail-Adresse wurde vom Verein vorgegeben.</p> type="email"
value={invitation.email}
readOnly
className="bg-muted"
/>
<p className="text-muted-foreground text-xs">
Ihre E-Mail-Adresse wurde vom Verein vorgegeben.
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Passwort festlegen *</Label> <Label>Passwort festlegen *</Label>
<Input type="password" name="password" placeholder="Mindestens 8 Zeichen" required minLength={8} /> <Input
type="password"
name="password"
placeholder="Mindestens 8 Zeichen"
required
minLength={8}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Passwort wiederholen *</Label> <Label>Passwort wiederholen *</Label>
<Input type="password" name="passwordConfirm" placeholder="Passwort bestätigen" required minLength={8} /> <Input
type="password"
name="passwordConfirm"
placeholder="Passwort bestätigen"
required
minLength={8}
/>
</div> </div>
<Button type="submit" className="w-full"> <Button type="submit" className="w-full">
@@ -115,8 +155,14 @@ export default async function PortalInvitePage({ params, searchParams }: Props)
</Button> </Button>
</form> </form>
<p className="mt-4 text-xs text-center text-muted-foreground"> <p className="text-muted-foreground mt-4 text-center text-xs">
Bereits ein Konto? <Link href={`/club/${slug}/portal`} className="text-primary underline">Anmelden</Link> Bereits ein Konto?{' '}
<Link
href={`/club/${slug}/portal`}
className="text-primary underline"
>
Anmelden
</Link>
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,10 +1,12 @@
import { createClient } from '@supabase/supabase-js';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Button } from '@kit/ui/button';
import { UserCircle, FileText, CreditCard, Shield } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { createClient } from '@supabase/supabase-js';
import { UserCircle, FileText, CreditCard, Shield } from 'lucide-react';
import { PortalLoginForm } from '@kit/site-builder/components'; import { PortalLoginForm } from '@kit/site-builder/components';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
interface Props { interface Props {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
@@ -18,15 +20,23 @@ export default async function MemberPortalPage({ params }: Props) {
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!, process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
); );
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single(); const { data: account } = await supabase
if (!account) return <div className="p-8 text-center">Organisation nicht gefunden</div>; .from('accounts')
.select('id, name')
.eq('slug', slug)
.single();
if (!account)
return <div className="p-8 text-center">Organisation nicht gefunden</div>;
// Check if user is already logged in // Check if user is already logged in
const { data: { user } } = await supabase.auth.getUser(); const {
data: { user },
} = await supabase.auth.getUser();
if (user) { if (user) {
// Check if this user is a member of this club // Check if this user is a member of this club
const { data: member } = await supabase.from('members') const { data: member } = await supabase
.from('members')
.select('id, first_name, last_name, status') .select('id, first_name, last_name, status')
.eq('account_id', account.id) .eq('account_id', account.id)
.eq('user_id', user.id) .eq('user_id', user.id)
@@ -35,45 +45,61 @@ export default async function MemberPortalPage({ params }: Props) {
if (member) { if (member) {
// Logged in member — show portal dashboard // Logged in member — show portal dashboard
return ( return (
<div className="min-h-screen bg-muted/30"> <div className="bg-muted/30 min-h-screen">
<header className="border-b bg-background px-6 py-4"> <header className="bg-background border-b px-6 py-4">
<div className="flex items-center justify-between max-w-4xl mx-auto"> <div className="mx-auto flex max-w-4xl items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Shield className="h-5 w-5 text-primary" /> <Shield className="text-primary h-5 w-5" />
<h1 className="text-lg font-bold">Mitgliederbereich {String(account.name)}</h1> <h1 className="text-lg font-bold">
Mitgliederbereich {String(account.name)}
</h1>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">{String(member.first_name)} {String(member.last_name)}</span> <span className="text-muted-foreground text-sm">
<Link href={`/club/${slug}`}><Button variant="ghost" size="sm"> Website</Button></Link> {String(member.first_name)} {String(member.last_name)}
</span>
<Link href={`/club/${slug}`}>
<Button variant="ghost" size="sm">
Website
</Button>
</Link>
</div> </div>
</div> </div>
</header> </header>
<main className="max-w-4xl mx-auto py-12 px-6"> <main className="mx-auto max-w-4xl px-6 py-12">
<h2 className="text-2xl font-bold mb-6">Willkommen, {String(member.first_name)}!</h2> <h2 className="mb-6 text-2xl font-bold">
Willkommen, {String(member.first_name)}!
</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Link href={`/club/${slug}/portal/profile`}> <Link href={`/club/${slug}/portal/profile`}>
<Card className="hover:border-primary transition-colors cursor-pointer"> <Card className="hover:border-primary cursor-pointer transition-colors">
<CardContent className="p-6 text-center"> <CardContent className="p-6 text-center">
<UserCircle className="mx-auto h-10 w-10 text-primary mb-3" /> <UserCircle className="text-primary mx-auto mb-3 h-10 w-10" />
<h3 className="font-semibold">Mein Profil</h3> <h3 className="font-semibold">Mein Profil</h3>
<p className="text-xs text-muted-foreground mt-1">Kontaktdaten und Datenschutz</p> <p className="text-muted-foreground mt-1 text-xs">
Kontaktdaten und Datenschutz
</p>
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
<Link href={`/club/${slug}/portal/documents`}> <Link href={`/club/${slug}/portal/documents`}>
<Card className="hover:border-primary transition-colors cursor-pointer"> <Card className="hover:border-primary cursor-pointer transition-colors">
<CardContent className="p-6 text-center"> <CardContent className="p-6 text-center">
<FileText className="mx-auto h-10 w-10 text-primary mb-3" /> <FileText className="text-primary mx-auto mb-3 h-10 w-10" />
<h3 className="font-semibold">Dokumente</h3> <h3 className="font-semibold">Dokumente</h3>
<p className="text-xs text-muted-foreground mt-1">Rechnungen und Bescheinigungen</p> <p className="text-muted-foreground mt-1 text-xs">
Rechnungen und Bescheinigungen
</p>
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
<Card> <Card>
<CardContent className="p-6 text-center"> <CardContent className="p-6 text-center">
<CreditCard className="mx-auto h-10 w-10 text-primary mb-3" /> <CreditCard className="text-primary mx-auto mb-3 h-10 w-10" />
<h3 className="font-semibold">Mitgliedsausweis</h3> <h3 className="font-semibold">Mitgliedsausweis</h3>
<p className="text-xs text-muted-foreground mt-1">Digital anzeigen</p> <p className="text-muted-foreground mt-1 text-xs">
Digital anzeigen
</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -85,14 +111,18 @@ export default async function MemberPortalPage({ params }: Props) {
// Not logged in or not a member — show login form // Not logged in or not a member — show login form
return ( return (
<div className="min-h-screen bg-muted/30"> <div className="bg-muted/30 min-h-screen">
<header className="border-b bg-background px-6 py-4"> <header className="bg-background border-b px-6 py-4">
<div className="flex items-center justify-between max-w-4xl mx-auto"> <div className="mx-auto flex max-w-4xl items-center justify-between">
<h1 className="text-lg font-bold">Mitgliederbereich</h1> <h1 className="text-lg font-bold">Mitgliederbereich</h1>
<Link href={`/club/${slug}`}><Button variant="ghost" size="sm"> Zurück zur Website</Button></Link> <Link href={`/club/${slug}`}>
<Button variant="ghost" size="sm">
Zurück zur Website
</Button>
</Link>
</div> </div>
</header> </header>
<main className="max-w-4xl mx-auto py-12 px-6"> <main className="mx-auto max-w-4xl px-6 py-12">
<PortalLoginForm slug={slug} accountName={String(account.name)} /> <PortalLoginForm slug={slug} accountName={String(account.name)} />
</main> </main>
</div> </div>

View File

@@ -0,0 +1,244 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { Provider, UserIdentity } from '@supabase/supabase-js';
import { createClient } from '@supabase/supabase-js';
import { Link2, Link2Off, Loader2 } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { OauthProviderLogoImage } from '@kit/ui/oauth-provider-logo-image';
import { toast } from '@kit/ui/sonner';
const PROVIDERS: Provider[] = ['google', 'apple', 'azure', 'github'];
const PROVIDER_LABELS: Record<string, string> = {
google: 'Google',
apple: 'Apple',
azure: 'Microsoft',
github: 'GitHub',
};
function getSupabaseClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
);
}
export function PortalLinkedAccounts({ slug }: { slug: string }) {
const [identities, setIdentities] = useState<UserIdentity[]>([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const loadIdentities = useCallback(async () => {
const supabase = getSupabaseClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (user?.identities) {
setIdentities(user.identities);
}
setLoading(false);
}, []);
useEffect(() => {
void loadIdentities();
}, [loadIdentities]);
const handleLink = async (provider: Provider) => {
setActionLoading(provider);
try {
const supabase = getSupabaseClient();
const redirectTo = `${window.location.origin}/club/${slug}/portal/profile`;
const { error } = await supabase.auth.linkIdentity({
provider,
options: { redirectTo },
});
if (error) {
toast.error(`Verknüpfung fehlgeschlagen: ${error.message}`);
setActionLoading(null);
}
} catch {
toast.error('Verbindungsfehler. Bitte versuchen Sie es erneut.');
setActionLoading(null);
}
};
const handleUnlink = async (identity: UserIdentity) => {
if (identities.length <= 1) {
toast.error('Sie benötigen mindestens eine Anmeldemethode.');
return;
}
setActionLoading(identity.id);
try {
const supabase = getSupabaseClient();
const { error } = await supabase.auth.unlinkIdentity(identity);
if (error) {
toast.error(`Trennung fehlgeschlagen: ${error.message}`);
} else {
toast.success(
`${PROVIDER_LABELS[identity.provider] ?? identity.provider} wurde getrennt.`,
);
await loadIdentities();
}
} catch {
toast.error('Verbindungsfehler. Bitte versuchen Sie es erneut.');
} finally {
setActionLoading(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</div>
);
}
const connectedProviders = identities
.filter((i) => i.provider !== 'email')
.map((i) => i.provider);
const availableProviders = PROVIDERS.filter(
(p) => !connectedProviders.includes(p),
);
return (
<div className="space-y-4">
{/* Connected accounts */}
{identities.filter((i) => i.provider !== 'email').length > 0 && (
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium">
Verknüpfte Konten
</p>
{identities
.filter((i) => i.provider !== 'email')
.map((identity) => (
<div
key={identity.id}
className="bg-muted/50 flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center">
<OauthProviderLogoImage providerId={identity.provider} />
</div>
<div>
<p className="text-sm font-medium capitalize">
{PROVIDER_LABELS[identity.provider] ?? identity.provider}
</p>
{identity.identity_data?.email && (
<p className="text-muted-foreground text-xs">
{identity.identity_data.email as string}
</p>
)}
</div>
</div>
{identities.length > 1 && (
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="sm"
disabled={actionLoading === identity.id}
>
{actionLoading === identity.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Link2Off className="h-4 w-4" />
)}
</Button>
}
/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Konto trennen?</AlertDialogTitle>
<AlertDialogDescription>
Möchten Sie die Verknüpfung mit{' '}
{PROVIDER_LABELS[identity.provider] ??
identity.provider}{' '}
wirklich aufheben? Sie können sich dann nicht mehr
darüber anmelden.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleUnlink(identity)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Trennen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
))}
</div>
)}
{/* Available providers to link */}
{availableProviders.length > 0 && (
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium">
Konto verknüpfen für schnellere Anmeldung
</p>
<div className="flex flex-wrap gap-2">
{availableProviders.map((provider) => (
<Button
key={provider}
variant="outline"
size="sm"
className="gap-2"
disabled={actionLoading === provider}
onClick={() => handleLink(provider)}
>
{actionLoading === provider ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<OauthProviderLogoImage providerId={provider} />
)}
{PROVIDER_LABELS[provider] ?? provider}
</Button>
))}
</div>
</div>
)}
{/* Info text when email-only */}
{identities.length <= 1 && availableProviders.length > 0 && (
<p className="text-muted-foreground text-xs">
Verknüpfen Sie ein Konto, um sich zukünftig schneller und ohne
Passwort anmelden zu können.
</p>
)}
</div>
);
}

View File

@@ -1,11 +1,25 @@
import { createClient } from '@supabase/supabase-js'; import Link from 'next/link';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createClient } from '@supabase/supabase-js';
import {
UserCircle,
Mail,
MapPin,
Phone,
Shield,
Calendar,
Link2,
} from 'lucide-react';
import { formatDate } from '@kit/shared/dates';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label'; import { Label } from '@kit/ui/label';
import { UserCircle, Mail, MapPin, Phone, Shield, Calendar } from 'lucide-react';
import Link from 'next/link'; import { PortalLinkedAccounts } from './_components/portal-linked-accounts';
interface Props { interface Props {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
@@ -19,15 +33,23 @@ export default async function PortalProfilePage({ params }: Props) {
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!, process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
); );
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single(); const { data: account } = await supabase
if (!account) return <div className="p-8 text-center">Organisation nicht gefunden</div>; .from('accounts')
.select('id, name')
.eq('slug', slug)
.single();
if (!account)
return <div className="p-8 text-center">Organisation nicht gefunden</div>;
// Get current user // Get current user
const { data: { user } } = await supabase.auth.getUser(); const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect(`/club/${slug}/portal`); if (!user) redirect(`/club/${slug}/portal`);
// Find member linked to this user // Find member linked to this user
const { data: member } = await supabase.from('members') const { data: member } = await supabase
.from('members')
.select('*') .select('*')
.eq('account_id', account.id) .eq('account_id', account.id)
.eq('user_id', user.id) .eq('user_id', user.id)
@@ -35,17 +57,20 @@ export default async function PortalProfilePage({ params }: Props) {
if (!member) { if (!member) {
return ( return (
<div className="min-h-screen bg-muted/30 flex items-center justify-center"> <div className="bg-muted/30 flex min-h-screen items-center justify-center">
<Card className="max-w-md"> <Card className="max-w-md">
<CardContent className="p-8 text-center"> <CardContent className="p-8 text-center">
<Shield className="mx-auto h-10 w-10 text-destructive mb-4" /> <Shield className="text-destructive mx-auto mb-4 h-10 w-10" />
<h2 className="text-lg font-bold">Kein Mitglied</h2> <h2 className="text-lg font-bold">Kein Mitglied</h2>
<p className="text-sm text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2 text-sm">
Ihr Benutzerkonto ist nicht mit einem Mitgliedsprofil in diesem Verein verknüpft. Ihr Benutzerkonto ist nicht mit einem Mitgliedsprofil in diesem
Bitte wenden Sie sich an Ihren Vereinsadministrator. Verein verknüpft. Bitte wenden Sie sich an Ihren
Vereinsadministrator.
</p> </p>
<Link href={`/club/${slug}/portal`}> <Link href={`/club/${slug}/portal`}>
<Button variant="outline" className="mt-4"> Zurück</Button> <Button variant="outline" className="mt-4">
Zurück
</Button>
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
@@ -56,28 +81,35 @@ export default async function PortalProfilePage({ params }: Props) {
const m = member; const m = member;
return ( return (
<div className="min-h-screen bg-muted/30"> <div className="bg-muted/30 min-h-screen">
<header className="border-b bg-background px-6 py-4"> <header className="bg-background border-b px-6 py-4">
<div className="flex items-center justify-between max-w-4xl mx-auto"> <div className="mx-auto flex max-w-4xl items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Shield className="h-5 w-5 text-primary" /> <Shield className="text-primary h-5 w-5" />
<h1 className="text-lg font-bold">Mein Profil</h1> <h1 className="text-lg font-bold">Mein Profil</h1>
</div> </div>
<Link href={`/club/${slug}/portal`}><Button variant="ghost" size="sm"> Zurück zum Portal</Button></Link> <Link href={`/club/${slug}/portal`}>
<Button variant="ghost" size="sm">
Zurück zum Portal
</Button>
</Link>
</div> </div>
</header> </header>
<main className="max-w-3xl mx-auto py-8 px-6 space-y-6"> <main className="mx-auto max-w-3xl space-y-6 px-6 py-8">
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 text-primary"> <div className="bg-primary/10 text-primary flex h-16 w-16 items-center justify-center rounded-full">
<UserCircle className="h-8 w-8" /> <UserCircle className="h-8 w-8" />
</div> </div>
<div> <div>
<h2 className="text-xl font-bold">{String(m.first_name)} {String(m.last_name)}</h2> <h2 className="text-xl font-bold">
<p className="text-sm text-muted-foreground"> {String(m.first_name)} {String(m.last_name)}
Nr. {String(m.member_number ?? '—')} Mitglied seit {m.entry_date ? new Date(String(m.entry_date)).toLocaleDateString('de-DE') : '—'} </h2>
<p className="text-muted-foreground text-sm">
Nr. {String(m.member_number ?? '—')} Mitglied seit{' '}
{formatDate(m.entry_date)}
</p> </p>
</div> </div>
</div> </div>
@@ -85,37 +117,111 @@ export default async function PortalProfilePage({ params }: Props) {
</Card> </Card>
<Card> <Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Mail className="h-4 w-4" />Kontaktdaten</CardTitle></CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-4 w-4" />
Kontaktdaten
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2"><Label>Vorname</Label><Input defaultValue={String(m.first_name)} readOnly /></div> <div className="space-y-2">
<div className="space-y-2"><Label>Nachname</Label><Input defaultValue={String(m.last_name)} readOnly /></div> <Label>Vorname</Label>
<div className="space-y-2"><Label>E-Mail</Label><Input defaultValue={String(m.email ?? '')} /></div> <Input defaultValue={String(m.first_name)} readOnly />
<div className="space-y-2"><Label>Telefon</Label><Input defaultValue={String(m.phone ?? '')} /></div> </div>
<div className="space-y-2"><Label>Mobil</Label><Input defaultValue={String(m.mobile ?? '')} /></div> <div className="space-y-2">
<Label>Nachname</Label>
<Input defaultValue={String(m.last_name)} readOnly />
</div>
<div className="space-y-2">
<Label>E-Mail</Label>
<Input defaultValue={String(m.email ?? '')} />
</div>
<div className="space-y-2">
<Label>Telefon</Label>
<Input defaultValue={String(m.phone ?? '')} />
</div>
<div className="space-y-2">
<Label>Mobil</Label>
<Input defaultValue={String(m.mobile ?? '')} />
</div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader><CardTitle className="flex items-center gap-2"><MapPin className="h-4 w-4" />Adresse</CardTitle></CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
Adresse
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2"><Label>Straße</Label><Input defaultValue={String(m.street ?? '')} /></div> <div className="space-y-2">
<div className="space-y-2"><Label>Hausnummer</Label><Input defaultValue={String(m.house_number ?? '')} /></div> <Label>Straße</Label>
<div className="space-y-2"><Label>PLZ</Label><Input defaultValue={String(m.postal_code ?? '')} /></div> <Input defaultValue={String(m.street ?? '')} />
<div className="space-y-2"><Label>Ort</Label><Input defaultValue={String(m.city ?? '')} /></div> </div>
<div className="space-y-2">
<Label>Hausnummer</Label>
<Input defaultValue={String(m.house_number ?? '')} />
</div>
<div className="space-y-2">
<Label>PLZ</Label>
<Input defaultValue={String(m.postal_code ?? '')} />
</div>
<div className="space-y-2">
<Label>Ort</Label>
<Input defaultValue={String(m.city ?? '')} />
</div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Shield className="h-4 w-4" />Datenschutz-Einwilligungen</CardTitle></CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2">
<Link2 className="h-4 w-4" />
Anmeldemethoden
</CardTitle>
</CardHeader>
<CardContent>
<PortalLinkedAccounts slug={slug} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Datenschutz-Einwilligungen
</CardTitle>
</CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{[ {[
{ key: 'gdpr_newsletter', label: 'Newsletter per E-Mail', value: m.gdpr_newsletter }, {
{ key: 'gdpr_internet', label: 'Veröffentlichung auf der Homepage', value: m.gdpr_internet }, key: 'gdpr_newsletter',
{ key: 'gdpr_print', label: 'Veröffentlichung in der Vereinszeitung', value: m.gdpr_print }, label: 'Newsletter per E-Mail',
{ key: 'gdpr_birthday_info', label: 'Geburtstagsinfo an Mitglieder', value: m.gdpr_birthday_info }, value: m.gdpr_newsletter,
},
{
key: 'gdpr_internet',
label: 'Veröffentlichung auf der Homepage',
value: m.gdpr_internet,
},
{
key: 'gdpr_print',
label: 'Veröffentlichung in der Vereinszeitung',
value: m.gdpr_print,
},
{
key: 'gdpr_birthday_info',
label: 'Geburtstagsinfo an Mitglieder',
value: m.gdpr_birthday_info,
},
].map(({ key, label, value }) => ( ].map(({ key, label, value }) => (
<label key={key} className="flex items-center gap-3 text-sm"> <label key={key} className="flex items-center gap-3 text-sm">
<input type="checkbox" defaultChecked={Boolean(value)} className="h-4 w-4 rounded border-input" /> <input
type="checkbox"
defaultChecked={Boolean(value)}
className="border-input h-4 w-4 rounded"
/>
{label} {label}
</label> </label>
))} ))}

View File

@@ -10,6 +10,7 @@ import {
User, User,
} from 'lucide-react'; } from 'lucide-react';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
@@ -21,8 +22,8 @@ import {
CardTitle, CardTitle,
} from '@kit/ui/card'; } from '@kit/ui/card';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps { interface PageProps {
params: Promise<{ account: string; bookingId: string }>; params: Promise<{ account: string; bookingId: string }>;
@@ -124,9 +125,7 @@ export default async function BookingDetailPage({ params }: PageProps) {
{STATUS_LABEL[status] ?? status} {STATUS_LABEL[status] ?? status}
</Badge> </Badge>
</div> </div>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">ID: {bookingId}</p>
ID: {bookingId}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -144,7 +143,7 @@ export default async function BookingDetailPage({ params }: PageProps) {
{room ? ( {room ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground text-sm">
Zimmernummer Zimmernummer
</span> </span>
<span className="font-medium"> <span className="font-medium">
@@ -153,14 +152,14 @@ export default async function BookingDetailPage({ params }: PageProps) {
</div> </div>
{room.name && ( {room.name && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground text-sm">
Name Name
</span> </span>
<span className="font-medium">{String(room.name)}</span> <span className="font-medium">{String(room.name)}</span>
</div> </div>
)} )}
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-muted-foreground">Typ</span> <span className="text-muted-foreground text-sm">Typ</span>
<span className="font-medium"> <span className="font-medium">
{String(room.room_type ?? '—')} {String(room.room_type ?? '—')}
</span> </span>
@@ -186,29 +185,25 @@ export default async function BookingDetailPage({ params }: PageProps) {
{guest ? ( {guest ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-muted-foreground">Name</span> <span className="text-muted-foreground text-sm">Name</span>
<span className="font-medium"> <span className="font-medium">
{String(guest.first_name)} {String(guest.last_name)} {String(guest.first_name)} {String(guest.last_name)}
</span> </span>
</div> </div>
{guest.email && ( {guest.email && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground text-sm">
E-Mail E-Mail
</span> </span>
<span className="font-medium"> <span className="font-medium">{String(guest.email)}</span>
{String(guest.email)}
</span>
</div> </div>
)} )}
{guest.phone && ( {guest.phone && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground text-sm">
Telefon Telefon
</span> </span>
<span className="font-medium"> <span className="font-medium">{String(guest.phone)}</span>
{String(guest.phone)}
</span>
</div> </div>
)} )}
</div> </div>
@@ -231,56 +226,30 @@ export default async function BookingDetailPage({ params }: PageProps) {
<CardContent> <CardContent>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground text-sm">
Check-in Check-in
</span> </span>
<span className="font-medium"> <span className="font-medium">
{booking.check_in {formatDate(booking.check_in)}
? new Date(String(booking.check_in)).toLocaleDateString(
'de-DE',
{
weekday: 'short',
day: '2-digit',
month: '2-digit',
year: 'numeric',
},
)
: '—'}
</span> </span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground text-sm">
Check-out Check-out
</span> </span>
<span className="font-medium"> <span className="font-medium">
{booking.check_out {formatDate(booking.check_out)}
? new Date(String(booking.check_out)).toLocaleDateString(
'de-DE',
{
weekday: 'short',
day: '2-digit',
month: '2-digit',
year: 'numeric',
},
)
: '—'}
</span> </span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground text-sm">
Erwachsene Erwachsene
</span> </span>
<span className="font-medium"> <span className="font-medium">{booking.adults ?? '—'}</span>
{booking.adults ?? '—'}
</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground text-sm">Kinder</span>
Kinder <span className="font-medium">{booking.children ?? 0}</span>
</span>
<span className="font-medium">
{booking.children ?? 0}
</span>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -294,7 +263,7 @@ export default async function BookingDetailPage({ params }: PageProps) {
<CardContent> <CardContent>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground text-sm">
Gesamtpreis Gesamtpreis
</span> </span>
<span className="text-2xl font-bold"> <span className="text-2xl font-bold">
@@ -305,7 +274,7 @@ export default async function BookingDetailPage({ params }: PageProps) {
</div> </div>
{booking.notes && ( {booking.notes && (
<div className="border-t pt-2"> <div className="border-t pt-2">
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground text-sm">
Notizen Notizen
</span> </span>
<p className="mt-1 text-sm">{String(booking.notes)}</p> <p className="mt-1 text-sm">{String(booking.notes)}</p>
@@ -320,9 +289,7 @@ export default async function BookingDetailPage({ params }: PageProps) {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Aktionen</CardTitle> <CardTitle>Aktionen</CardTitle>
<CardDescription> <CardDescription>Status der Buchung ändern</CardDescription>
Status der Buchung ändern
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
@@ -350,10 +317,10 @@ export default async function BookingDetailPage({ params }: PageProps) {
)} )}
{status === 'cancelled' || status === 'checked_out' ? ( {status === 'cancelled' || status === 'checked_out' ? (
<p className="text-sm text-muted-foreground py-2"> <p className="text-muted-foreground py-2 text-sm">
Diese Buchung ist{' '} Diese Buchung ist{' '}
{status === 'cancelled' ? 'storniert' : 'abgeschlossen'} keine {status === 'cancelled' ? 'storniert' : 'abgeschlossen'}
weiteren Aktionen verfügbar. keine weiteren Aktionen verfügbar.
</p> </p>
) : null} ) : null}
</div> </div>

View File

@@ -2,15 +2,14 @@ import Link from 'next/link';
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react'; import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -43,7 +42,11 @@ function getFirstWeekday(year: number, month: number): number {
return day === 0 ? 6 : day - 1; return day === 0 ? 6 : day - 1;
} }
function isDateInRange(date: string, checkIn: string, checkOut: string): boolean { function isDateInRange(
date: string,
checkIn: string,
checkOut: string,
): boolean {
return date >= checkIn && date < checkOut; return date >= checkIn && date < checkOut;
} }
@@ -101,7 +104,11 @@ export default async function BookingCalendarPage({ params }: PageProps) {
} }
// Build calendar grid cells // Build calendar grid cells
const cells: Array<{ day: number | null; occupied: boolean; isToday: boolean }> = []; const cells: Array<{
day: number | null;
occupied: boolean;
isToday: boolean;
}> = [];
// Empty cells before first day // Empty cells before first day
for (let i = 0; i < firstWeekday; i++) { for (let i = 0; i < firstWeekday; i++) {
@@ -158,11 +165,11 @@ export default async function BookingCalendarPage({ params }: PageProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{/* Weekday Header */} {/* Weekday Header */}
<div className="grid grid-cols-7 gap-1 mb-1"> <div className="mb-1 grid grid-cols-7 gap-1">
{WEEKDAYS.map((day) => ( {WEEKDAYS.map((day) => (
<div <div
key={day} key={day}
className="text-center text-xs font-medium text-muted-foreground py-2" className="text-muted-foreground py-2 text-center text-xs font-medium"
> >
{day} {day}
</div> </div>
@@ -180,13 +187,13 @@ export default async function BookingCalendarPage({ params }: PageProps) {
: cell.occupied : cell.occupied
? 'bg-primary/15 text-primary font-semibold' ? 'bg-primary/15 text-primary font-semibold'
: 'bg-muted/30 hover:bg-muted/50' : 'bg-muted/30 hover:bg-muted/50'
} ${cell.isToday ? 'ring-2 ring-primary ring-offset-1' : ''}`} } ${cell.isToday ? 'ring-primary ring-2 ring-offset-1' : ''}`}
> >
{cell.day !== null && ( {cell.day !== null && (
<> <>
<span>{cell.day}</span> <span>{cell.day}</span>
{cell.occupied && ( {cell.occupied && (
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 h-1.5 w-1.5 rounded-full bg-primary" /> <span className="bg-primary absolute bottom-1 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full" />
)} )}
</> </>
)} )}
@@ -195,17 +202,17 @@ export default async function BookingCalendarPage({ params }: PageProps) {
</div> </div>
{/* Legend */} {/* Legend */}
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground"> <div className="text-muted-foreground mt-4 flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm bg-primary/15" /> <span className="bg-primary/15 inline-block h-3 w-3 rounded-sm" />
Belegt Belegt
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm bg-muted/30" /> <span className="bg-muted/30 inline-block h-3 w-3 rounded-sm" />
Frei Frei
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm ring-2 ring-primary" /> <span className="ring-primary inline-block h-3 w-3 rounded-sm ring-2" />
Heute Heute
</div> </div>
</div> </div>
@@ -217,7 +224,7 @@ export default async function BookingCalendarPage({ params }: PageProps) {
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
Buchungen in diesem Monat Buchungen in diesem Monat
</p> </p>
<p className="text-2xl font-bold">{bookings.data.length}</p> <p className="text-2xl font-bold">{bookings.data.length}</p>

View File

@@ -1,14 +1,13 @@
import { UserCircle, Plus } from 'lucide-react'; import { UserCircle, Plus } from 'lucide-react';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createBookingManagementApi } from '@kit/booking-management/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -40,7 +39,7 @@ export default async function GuestsPage({ params }: PageProps) {
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-muted-foreground">Gästeverwaltung</p> <p className="text-muted-foreground">Gästeverwaltung</p>
<Button> <Button data-test="guests-new-btn">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neuer Gast Neuer Gast
</Button> </Button>
@@ -62,7 +61,7 @@ export default async function GuestsPage({ params }: PageProps) {
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Name</th> <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">E-Mail</th>
<th className="p-3 text-left font-medium">Telefon</th> <th className="p-3 text-left font-medium">Telefon</th>
@@ -72,9 +71,13 @@ export default async function GuestsPage({ params }: PageProps) {
</thead> </thead>
<tbody> <tbody>
{guests.map((guest: Record<string, unknown>) => ( {guests.map((guest: Record<string, unknown>) => (
<tr key={String(guest.id)} className="border-b hover:bg-muted/30"> <tr
key={String(guest.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium"> <td className="p-3 font-medium">
{String(guest.last_name ?? '')}, {String(guest.first_name ?? '')} {String(guest.last_name ?? '')},{' '}
{String(guest.first_name ?? '')}
</td> </td>
<td className="p-3">{String(guest.email ?? '—')}</td> <td className="p-3">{String(guest.email ?? '—')}</td>
<td className="p-3">{String(guest.phone ?? '—')}</td> <td className="p-3">{String(guest.phone ?? '—')}</td>

View File

@@ -1,15 +1,22 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createBookingManagementApi } from '@kit/booking-management/api'; import { createBookingManagementApi } from '@kit/booking-management/api';
import { CreateBookingForm } from '@kit/booking-management/components'; import { CreateBookingForm } from '@kit/booking-management/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { params: Promise<{ account: string }> } import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string }>;
}
export default async function NewBookingPage({ params }: Props) { export default async function NewBookingPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) { if (!acct) {
return ( return (
<CmsPageShell account={account} title="Neue Buchung"> <CmsPageShell account={account} title="Neue Buchung">
@@ -22,13 +29,20 @@ export default async function NewBookingPage({ params }: Props) {
const rooms = await api.listRooms(acct.id); const rooms = await api.listRooms(acct.id);
return ( return (
<CmsPageShell account={account} title="Neue Buchung" description="Buchung erstellen"> <CmsPageShell
<CreateBookingForm account={account}
accountId={acct.id} title="Neue Buchung"
account={account} description="Buchung erstellen"
>
<CreateBookingForm
accountId={acct.id}
account={account}
rooms={(rooms ?? []).map((r: Record<string, unknown>) => ({ rooms={(rooms ?? []).map((r: Record<string, unknown>) => ({
id: String(r.id), roomNumber: String(r.room_number), name: String(r.name ?? ''), pricePerNight: Number(r.price_per_night ?? 0) id: String(r.id),
}))} roomNumber: String(r.room_number),
name: String(r.name ?? ''),
pricePerNight: Number(r.price_per_night ?? 0),
}))}
/> />
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -2,18 +2,18 @@ import Link from 'next/link';
import { BedDouble, CalendarCheck, Plus, Euro, Search } from 'lucide-react'; import { BedDouble, CalendarCheck, Plus, Euro, Search } from 'lucide-react';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { createBookingManagementApi } from '@kit/booking-management/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card'; import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -42,7 +42,10 @@ const STATUS_LABEL: Record<string, string> = {
no_show: 'Nicht erschienen', no_show: 'Nicht erschienen',
}; };
export default async function BookingsPage({ params, searchParams }: PageProps) { export default async function BookingsPage({
params,
searchParams,
}: PageProps) {
const { account } = await params; const { account } = await params;
const search = await searchParams; const search = await searchParams;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
@@ -87,9 +90,9 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
// Post-filter by search query (guest name or room name/number) // Post-filter by search query (guest name or room name/number)
if (searchQuery) { if (searchQuery) {
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
bookingsData = bookingsData.filter((b) => { bookingsData = bookingsData.filter((booking) => {
const room = b.room as Record<string, string> | null; const room = booking.room as Record<string, string> | null;
const guest = b.guest as Record<string, string> | null; const guest = booking.guest as Record<string, string> | null;
const roomName = (room?.name ?? '').toLowerCase(); const roomName = (room?.name ?? '').toLowerCase();
const roomNumber = (room?.room_number ?? '').toLowerCase(); const roomNumber = (room?.room_number ?? '').toLowerCase();
const guestFirst = (guest?.first_name ?? '').toLowerCase(); const guestFirst = (guest?.first_name ?? '').toLowerCase();
@@ -104,7 +107,8 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
} }
const activeBookings = bookingsData.filter( const activeBookings = bookingsData.filter(
(b) => b.status === 'confirmed' || b.status === 'checked_in', (booking) =>
booking.status === 'confirmed' || booking.status === 'checked_in',
); );
const totalPages = Math.ceil(total / PAGE_SIZE); const totalPages = Math.ceil(total / PAGE_SIZE);
@@ -119,7 +123,7 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
</p> </p>
<Link href={`/home/${account}/bookings/new`}> <Link href={`/home/${account}/bookings/new`}>
<Button> <Button data-test="bookings-new-btn">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neue Buchung Neue Buchung
</Button> </Button>
@@ -148,7 +152,7 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
{/* Search */} {/* Search */}
<form className="flex items-center gap-2"> <form className="flex items-center gap-2">
<div className="relative max-w-sm flex-1"> <div className="relative max-w-sm flex-1">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="text-muted-foreground absolute top-1/2 left-2.5 h-4 w-4 -translate-y-1/2" />
<Input <Input
name="q" name="q"
defaultValue={searchQuery} defaultValue={searchQuery}
@@ -200,7 +204,7 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Zimmer</th> <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">Gast</th>
<th className="p-3 text-left font-medium">Anreise</th> <th className="p-3 text-left font-medium">Anreise</th>
@@ -211,13 +215,19 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
</thead> </thead>
<tbody> <tbody>
{bookingsData.map((booking) => { {bookingsData.map((booking) => {
const room = booking.room as Record<string, string> | null; const room = booking.room as Record<
const guest = booking.guest as Record<string, string> | null; string,
string
> | null;
const guest = booking.guest as Record<
string,
string
> | null;
return ( return (
<tr <tr
key={String(booking.id)} key={String(booking.id)}
className="border-b hover:bg-muted/30" className="hover:bg-muted/30 border-b"
> >
<td className="p-3"> <td className="p-3">
<Link <Link
@@ -235,18 +245,10 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
: '—'} : '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
{booking.check_in {formatDate(booking.check_in)}
? new Date(
String(booking.check_in),
).toLocaleDateString('de-DE')
: '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
{booking.check_out {formatDate(booking.check_out)}
? new Date(
String(booking.check_out),
).toLocaleDateString('de-DE')
: '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
<Badge <Badge
@@ -274,14 +276,12 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && !searchQuery && ( {totalPages > 1 && !searchQuery && (
<div className="flex items-center justify-between border-t px-2 py-4"> <div className="flex items-center justify-between border-t px-2 py-4">
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
Seite {page} von {totalPages} ({total} Einträge) Seite {page} von {totalPages} ({total} Einträge)
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{page > 1 ? ( {page > 1 ? (
<Link <Link href={`/home/${account}/bookings?page=${page - 1}`}>
href={`/home/${account}/bookings?page=${page - 1}`}
>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
Zurück Zurück
</Button> </Button>
@@ -293,9 +293,7 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
)} )}
{page < totalPages ? ( {page < totalPages ? (
<Link <Link href={`/home/${account}/bookings?page=${page + 1}`}>
href={`/home/${account}/bookings?page=${page + 1}`}
>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
Weiter Weiter
</Button> </Button>

View File

@@ -1,15 +1,14 @@
import { BedDouble, Plus } from 'lucide-react'; import { BedDouble, Plus } from 'lucide-react';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createBookingManagementApi } from '@kit/booking-management/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -41,7 +40,7 @@ export default async function RoomsPage({ params }: PageProps) {
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-muted-foreground">Zimmerverwaltung</p> <p className="text-muted-foreground">Zimmerverwaltung</p>
<Button> <Button data-test="rooms-new-btn">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neues Zimmer Neues Zimmer
</Button> </Button>
@@ -63,26 +62,37 @@ export default async function RoomsPage({ params }: PageProps) {
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Zimmernr.</th> <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">Name</th>
<th className="p-3 text-left font-medium">Typ</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">Kapazität</th>
<th className="p-3 text-right font-medium">Preis/Nacht</th> <th className="p-3 text-right font-medium">
Preis/Nacht
</th>
<th className="p-3 text-center font-medium">Aktiv</th> <th className="p-3 text-center font-medium">Aktiv</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rooms.map((room: Record<string, unknown>) => ( {rooms.map((room: Record<string, unknown>) => (
<tr key={String(room.id)} className="border-b hover:bg-muted/30"> <tr
key={String(room.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-mono text-xs"> <td className="p-3 font-mono text-xs">
{String(room.room_number ?? '—')} {String(room.room_number ?? '—')}
</td> </td>
<td className="p-3 font-medium">{String(room.name ?? '—')}</td> <td className="p-3 font-medium">
<td className="p-3"> {String(room.name ?? '—')}
<Badge variant="outline">{String(room.room_type ?? '—')}</Badge> </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>
<td className="p-3 text-right">{String(room.capacity ?? '—')}</td>
<td className="p-3 text-right"> <td className="p-3 text-right">
{room.price_per_night != null {room.price_per_night != null
? `${Number(room.price_per_night).toFixed(2)}` ? `${Number(room.price_per_night).toFixed(2)}`

View File

@@ -1,11 +1,12 @@
import { ClipboardCheck, Calendar } from 'lucide-react'; import { ClipboardCheck, Calendar } from 'lucide-react';
import { createCourseManagementApi } from '@kit/course-management/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
@@ -14,7 +15,10 @@ interface PageProps {
searchParams: Promise<Record<string, string | string[] | undefined>>; searchParams: Promise<Record<string, string | string[] | undefined>>;
} }
export default async function AttendancePage({ params, searchParams }: PageProps) { export default async function AttendancePage({
params,
searchParams,
}: PageProps) {
const { account, courseId } = await params; const { account, courseId } = await params;
const search = await searchParams; const search = await searchParams;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
@@ -26,16 +30,23 @@ export default async function AttendancePage({ params, searchParams }: PageProps
api.getParticipants(courseId), api.getParticipants(courseId),
]); ]);
if (!course) return <div>Kurs nicht gefunden</div>; if (!course) return <AccountNotFound />;
const selectedSessionId = (search.session as string) ?? (sessions.length > 0 ? String((sessions[0] as Record<string, unknown>).id) : null); const selectedSessionId =
(search.session as string) ??
(sessions.length > 0
? String((sessions[0] as Record<string, unknown>).id)
: null);
const attendance = selectedSessionId const attendance = selectedSessionId
? await api.getAttendance(selectedSessionId) ? await api.getAttendance(selectedSessionId)
: []; : [];
const attendanceMap = new Map( const attendanceMap = new Map(
attendance.map((a: Record<string, unknown>) => [String(a.participant_id), Boolean(a.present)]), attendance.map((a: Record<string, unknown>) => [
String(a.participant_id),
Boolean(a.present),
]),
); );
return ( return (
@@ -70,9 +81,12 @@ export default async function AttendancePage({ params, searchParams }: PageProps
key={String(s.id)} key={String(s.id)}
href={`/home/${account}/courses/${courseId}/attendance?session=${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"> <Badge
variant={isSelected ? 'default' : 'outline'}
className="cursor-pointer px-3 py-1"
>
{s.session_date {s.session_date
? new Date(String(s.session_date)).toLocaleDateString('de-DE') ? formatDate(s.session_date as string)
: String(s.id)} : String(s.id)}
</Badge> </Badge>
</a> </a>
@@ -92,28 +106,38 @@ export default async function AttendancePage({ params, searchParams }: PageProps
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{participants.length === 0 ? ( {participants.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground"> <p className="text-muted-foreground py-6 text-center text-sm">
Keine Teilnehmer in diesem Kurs Keine Teilnehmer in diesem Kurs
</p> </p>
) : ( ) : (
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Teilnehmer</th> <th className="p-3 text-left font-medium">
<th className="p-3 text-center font-medium">Anwesend</th> Teilnehmer
</th>
<th className="p-3 text-center font-medium">
Anwesend
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{participants.map((p: Record<string, unknown>) => ( {participants.map((p: Record<string, unknown>) => (
<tr key={String(p.id)} className="border-b hover:bg-muted/30"> <tr
key={String(p.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium"> <td className="p-3 font-medium">
{String(p.last_name ?? '')}, {String(p.first_name ?? '')} {String(p.last_name ?? '')},{' '}
{String(p.first_name ?? '')}
</td> </td>
<td className="p-3 text-center"> <td className="p-3 text-center">
<input <input
type="checkbox" type="checkbox"
defaultChecked={attendanceMap.get(String(p.id)) ?? false} defaultChecked={
attendanceMap.get(String(p.id)) ?? false
}
className="h-4 w-4 rounded border-gray-300" className="h-4 w-4 rounded border-gray-300"
aria-label={`Anwesenheit ${String(p.last_name)}`} aria-label={`Anwesenheit ${String(p.last_name)}`}
/> />

View File

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

View File

@@ -2,13 +2,14 @@ import Link from 'next/link';
import { Plus, Users } from 'lucide-react'; import { Plus, Users } from 'lucide-react';
import { createCourseManagementApi } from '@kit/course-management/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
@@ -16,7 +17,10 @@ interface PageProps {
params: Promise<{ account: string; courseId: string }>; params: Promise<{ account: string; courseId: string }>;
} }
const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'outline' | 'destructive'> = { const STATUS_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
enrolled: 'default', enrolled: 'default',
waitlisted: 'secondary', waitlisted: 'secondary',
cancelled: 'destructive', cancelled: 'destructive',
@@ -40,7 +44,7 @@ export default async function ParticipantsPage({ params }: PageProps) {
api.getParticipants(courseId), api.getParticipants(courseId),
]); ]);
if (!course) return <div>Kurs nicht gefunden</div>; if (!course) return <AccountNotFound />;
return ( return (
<CmsPageShell account={account} title="Teilnehmer"> <CmsPageShell account={account} title="Teilnehmer">
@@ -49,10 +53,11 @@ export default async function ParticipantsPage({ params }: PageProps) {
<div> <div>
<h1 className="text-2xl font-bold">Teilnehmer</h1> <h1 className="text-2xl font-bold">Teilnehmer</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{String((course as Record<string, unknown>).name)} {participants.length} Teilnehmer {String((course as Record<string, unknown>).name)} {' '}
{participants.length} Teilnehmer
</p> </p>
</div> </div>
<Button> <Button data-test="participants-add-btn">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Teilnehmer anmelden Teilnehmer anmelden
</Button> </Button>
@@ -74,31 +79,39 @@ export default async function ParticipantsPage({ params }: PageProps) {
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Name</th> <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">E-Mail</th>
<th className="p-3 text-left font-medium">Telefon</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">Status</th>
<th className="p-3 text-left font-medium">Anmeldedatum</th> <th className="p-3 text-left font-medium">
Anmeldedatum
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{participants.map((p: Record<string, unknown>) => ( {participants.map((p: Record<string, unknown>) => (
<tr key={String(p.id)} className="border-b hover:bg-muted/30"> <tr
key={String(p.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium"> <td className="p-3 font-medium">
{String(p.last_name ?? '')}, {String(p.first_name ?? '')} {String(p.last_name ?? '')},{' '}
{String(p.first_name ?? '')}
</td> </td>
<td className="p-3">{String(p.email ?? '—')}</td> <td className="p-3">{String(p.email ?? '—')}</td>
<td className="p-3">{String(p.phone ?? '—')}</td> <td className="p-3">{String(p.phone ?? '—')}</td>
<td className="p-3"> <td className="p-3">
<Badge variant={STATUS_VARIANT[String(p.status)] ?? 'secondary'}> <Badge
variant={
STATUS_VARIANT[String(p.status)] ?? 'secondary'
}
>
{STATUS_LABEL[String(p.status)] ?? String(p.status)} {STATUS_LABEL[String(p.status)] ?? String(p.status)}
</Badge> </Badge>
</td> </td>
<td className="p-3"> <td className="p-3">
{p.enrolled_at {formatDate(p.enrolled_at as string)}
? new Date(String(p.enrolled_at)).toLocaleDateString('de-DE')
: '—'}
</td> </td>
</tr> </tr>
))} ))}

View File

@@ -2,15 +2,15 @@ import Link from 'next/link';
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react'; import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
import { createCourseManagementApi } from '@kit/course-management/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -67,10 +67,14 @@ export default async function CourseCalendarPage({ params }: PageProps) {
const courseDates = new Set<number>(); const courseDates = new Set<number>();
for (const course of courses.data) { for (const course of courses.data) {
const c = course as Record<string, unknown>; const courseItem = course as Record<string, unknown>;
if (c.status === 'cancelled') continue; if (courseItem.status === 'cancelled') continue;
const startDate = c.start_date ? new Date(String(c.start_date)) : null; const startDate = courseItem.start_date
const endDate = c.end_date ? new Date(String(c.end_date)) : null; ? new Date(String(courseItem.start_date))
: null;
const endDate = courseItem.end_date
? new Date(String(courseItem.end_date))
: null;
if (!startDate) continue; if (!startDate) continue;
@@ -86,7 +90,11 @@ export default async function CourseCalendarPage({ params }: PageProps) {
} }
// Build calendar grid // Build calendar grid
const cells: Array<{ day: number | null; hasCourse: boolean; isToday: boolean }> = []; const cells: Array<{
day: number | null;
hasCourse: boolean;
isToday: boolean;
}> = [];
for (let i = 0; i < firstWeekday; i++) { for (let i = 0; i < firstWeekday; i++) {
cells.push({ day: null, hasCourse: false, isToday: false }); cells.push({ day: null, hasCourse: false, isToday: false });
@@ -96,7 +104,10 @@ export default async function CourseCalendarPage({ params }: PageProps) {
cells.push({ cells.push({
day: d, day: d,
hasCourse: courseDates.has(d), hasCourse: courseDates.has(d),
isToday: d === now.getDate() && month === now.getMonth() && year === now.getFullYear(), isToday:
d === now.getDate() &&
month === now.getMonth() &&
year === now.getFullYear(),
}); });
} }
@@ -105,8 +116,8 @@ export default async function CourseCalendarPage({ params }: PageProps) {
} }
const activeCourses = courses.data.filter( const activeCourses = courses.data.filter(
(c: Record<string, unknown>) => (courseItem: Record<string, unknown>) =>
c.status === 'open' || c.status === 'running', courseItem.status === 'open' || courseItem.status === 'running',
); );
return ( return (
@@ -120,9 +131,7 @@ export default async function CourseCalendarPage({ params }: PageProps) {
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
<p className="text-muted-foreground"> <p className="text-muted-foreground">Kurstermine im Überblick</p>
Kurstermine im Überblick
</p>
</div> </div>
</div> </div>
@@ -143,11 +152,11 @@ export default async function CourseCalendarPage({ params }: PageProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{/* Weekday Header */} {/* Weekday Header */}
<div className="grid grid-cols-7 gap-1 mb-1"> <div className="mb-1 grid grid-cols-7 gap-1">
{WEEKDAYS.map((day) => ( {WEEKDAYS.map((day) => (
<div <div
key={day} key={day}
className="text-center text-xs font-medium text-muted-foreground py-2" className="text-muted-foreground py-2 text-center text-xs font-medium"
> >
{day} {day}
</div> </div>
@@ -163,15 +172,15 @@ export default async function CourseCalendarPage({ params }: PageProps) {
cell.day === null cell.day === null
? 'bg-transparent' ? 'bg-transparent'
: cell.hasCourse : cell.hasCourse
? 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 font-semibold' ? 'bg-emerald-500/15 font-semibold text-emerald-700 dark:text-emerald-400'
: 'bg-muted/30 hover:bg-muted/50' : 'bg-muted/30 hover:bg-muted/50'
} ${cell.isToday ? 'ring-2 ring-primary ring-offset-1' : ''}`} } ${cell.isToday ? 'ring-primary ring-2 ring-offset-1' : ''}`}
> >
{cell.day !== null && ( {cell.day !== null && (
<> <>
<span>{cell.day}</span> <span>{cell.day}</span>
{cell.hasCourse && ( {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" /> <span className="absolute bottom-1 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full bg-emerald-500" />
)} )}
</> </>
)} )}
@@ -180,17 +189,17 @@ export default async function CourseCalendarPage({ params }: PageProps) {
</div> </div>
{/* Legend */} {/* Legend */}
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground"> <div className="text-muted-foreground mt-4 flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm bg-emerald-500/15" /> <span className="inline-block h-3 w-3 rounded-sm bg-emerald-500/15" />
Kurstag Kurstag
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm bg-muted/30" /> <span className="bg-muted/30 inline-block h-3 w-3 rounded-sm" />
Frei Frei
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm ring-2 ring-primary" /> <span className="ring-primary inline-block h-3 w-3 rounded-sm ring-2" />
Heute Heute
</div> </div>
</div> </div>
@@ -204,7 +213,7 @@ export default async function CourseCalendarPage({ params }: PageProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{activeCourses.length === 0 ? ( {activeCourses.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
Keine aktiven Kurse in diesem Monat. Keine aktiven Kurse in diesem Monat.
</p> </p>
) : ( ) : (
@@ -221,18 +230,19 @@ export default async function CourseCalendarPage({ params }: PageProps) {
> >
{String(course.name)} {String(course.name)}
</Link> </Link>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs">
{course.start_date {formatDate(course.start_date as string)} {' '}
? new Date(String(course.start_date)).toLocaleDateString('de-DE') {formatDate(course.end_date as string)}
: '—'}{' '}
{' '}
{course.end_date
? new Date(String(course.end_date)).toLocaleDateString('de-DE')
: '—'}
</p> </p>
</div> </div>
<Badge variant={String(course.status) === 'running' ? 'info' : 'default'}> <Badge
{String(course.status) === 'running' ? 'Laufend' : 'Offen'} variant={
String(course.status) === 'running' ? 'info' : 'default'
}
>
{String(course.status) === 'running'
? 'Laufend'
: 'Offen'}
</Badge> </Badge>
</div> </div>
))} ))}

View File

@@ -1,14 +1,13 @@
import { FolderTree, Plus } from 'lucide-react'; import { FolderTree, Plus } from 'lucide-react';
import { createCourseManagementApi } from '@kit/course-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -34,7 +33,7 @@ export default async function CategoriesPage({ params }: PageProps) {
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-muted-foreground">Kurskategorien verwalten</p> <p className="text-muted-foreground">Kurskategorien verwalten</p>
<Button> <Button data-test="categories-new-btn">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neue Kategorie Neue Kategorie
</Button> </Button>
@@ -56,17 +55,24 @@ export default async function CategoriesPage({ params }: PageProps) {
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Name</th> <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">
<th className="p-3 text-left font-medium">Übergeordnet</th> Beschreibung
</th>
<th className="p-3 text-left font-medium">
Übergeordnet
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{categories.map((cat: Record<string, unknown>) => ( {categories.map((cat: Record<string, unknown>) => (
<tr key={String(cat.id)} className="border-b hover:bg-muted/30"> <tr
key={String(cat.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">{String(cat.name)}</td> <td className="p-3 font-medium">{String(cat.name)}</td>
<td className="p-3 text-muted-foreground"> <td className="text-muted-foreground p-3">
{String(cat.description ?? '—')} {String(cat.description ?? '—')}
</td> </td>
<td className="p-3">{String(cat.parent_id ?? '—')}</td> <td className="p-3">{String(cat.parent_id ?? '—')}</td>

View File

@@ -1,14 +1,13 @@
import { GraduationCap, Plus } from 'lucide-react'; import { GraduationCap, Plus } from 'lucide-react';
import { createCourseManagementApi } from '@kit/course-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -34,7 +33,7 @@ export default async function InstructorsPage({ params }: PageProps) {
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-muted-foreground">Dozentenpool verwalten</p> <p className="text-muted-foreground">Dozentenpool verwalten</p>
<Button> <Button data-test="instructors-new-btn">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neuer Dozent Neuer Dozent
</Button> </Button>
@@ -56,23 +55,33 @@ export default async function InstructorsPage({ params }: PageProps) {
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Name</th> <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">E-Mail</th>
<th className="p-3 text-left font-medium">Telefon</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-left font-medium">
<th className="p-3 text-right font-medium">Stundensatz</th> Qualifikation
</th>
<th className="p-3 text-right font-medium">
Stundensatz
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{instructors.map((inst: Record<string, unknown>) => ( {instructors.map((inst: Record<string, unknown>) => (
<tr key={String(inst.id)} className="border-b hover:bg-muted/30"> <tr
key={String(inst.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium"> <td className="p-3 font-medium">
{String(inst.last_name ?? '')}, {String(inst.first_name ?? '')} {String(inst.last_name ?? '')},{' '}
{String(inst.first_name ?? '')}
</td> </td>
<td className="p-3">{String(inst.email ?? '—')}</td> <td className="p-3">{String(inst.email ?? '—')}</td>
<td className="p-3">{String(inst.phone ?? '—')}</td> <td className="p-3">{String(inst.phone ?? '—')}</td>
<td className="p-3">{String(inst.qualification ?? '—')}</td> <td className="p-3">
{String(inst.qualification ?? '—')}
</td>
<td className="p-3 text-right"> <td className="p-3 text-right">
{inst.hourly_rate != null {inst.hourly_rate != null
? `${Number(inst.hourly_rate).toFixed(2)}` ? `${Number(inst.hourly_rate).toFixed(2)}`

View File

@@ -1,14 +1,13 @@
import { MapPin, Plus } from 'lucide-react'; import { MapPin, Plus } from 'lucide-react';
import { createCourseManagementApi } from '@kit/course-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -33,8 +32,10 @@ export default async function LocationsPage({ params }: PageProps) {
<CmsPageShell account={account} title="Orte"> <CmsPageShell account={account} title="Orte">
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-muted-foreground">Kurs- und Veranstaltungsorte verwalten</p> <p className="text-muted-foreground">
<Button> Kurs- und Veranstaltungsorte verwalten
</p>
<Button data-test="locations-new-btn">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neuer Ort Neuer Ort
</Button> </Button>
@@ -56,7 +57,7 @@ export default async function LocationsPage({ params }: PageProps) {
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Name</th> <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">Adresse</th>
<th className="p-3 text-left font-medium">Raum</th> <th className="p-3 text-left font-medium">Raum</th>
@@ -65,7 +66,10 @@ export default async function LocationsPage({ params }: PageProps) {
</thead> </thead>
<tbody> <tbody>
{locations.map((loc: Record<string, unknown>) => ( {locations.map((loc: Record<string, unknown>) => (
<tr key={String(loc.id)} className="border-b hover:bg-muted/30"> <tr
key={String(loc.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">{String(loc.name)}</td> <td className="p-3 font-medium">{String(loc.name)}</td>
<td className="p-3"> <td className="p-3">
{[loc.street, loc.postal_code, loc.city] {[loc.street, loc.postal_code, loc.city]
@@ -74,7 +78,9 @@ export default async function LocationsPage({ params }: PageProps) {
.join(', ') || '—'} .join(', ') || '—'}
</td> </td>
<td className="p-3">{String(loc.room ?? '—')}</td> <td className="p-3">{String(loc.room ?? '—')}</td>
<td className="p-3 text-right">{String(loc.capacity ?? '—')}</td> <td className="p-3 text-right">
{String(loc.capacity ?? '—')}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -1,18 +1,29 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateCourseForm } from '@kit/course-management/components'; import { CreateCourseForm } from '@kit/course-management/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { params: Promise<{ account: string }> } import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string }>;
}
export default async function NewCoursePage({ params }: Props) { export default async function NewCoursePage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell account={account} title="Neuer Kurs" description="Kurs anlegen"> <CmsPageShell
account={account}
title="Neuer Kurs"
description="Kurs anlegen"
>
<CreateCourseForm accountId={acct.id} account={account} /> <CreateCourseForm accountId={acct.id} account={account} />
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -1,19 +1,30 @@
import Link from 'next/link'; import Link from 'next/link';
import { ChevronLeft, ChevronRight, GraduationCap, Plus, Users, Calendar, Euro } from 'lucide-react'; import {
ChevronLeft,
ChevronRight,
GraduationCap,
Plus,
Users,
Calendar,
Euro,
} from 'lucide-react';
import { createCourseManagementApi } from '@kit/course-management/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card'; import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found'; import {
import { COURSE_STATUS_VARIANT, COURSE_STATUS_LABEL } from '~/lib/status-badges'; COURSE_STATUS_VARIANT,
COURSE_STATUS_LABEL,
} from '~/lib/status-badges';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -50,12 +61,10 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-muted-foreground"> <p className="text-muted-foreground">Kursangebot verwalten</p>
Kursangebot verwalten
</p>
<Link href={`/home/${account}/courses/new`}> <Link href={`/home/${account}/courses/new`}>
<Button> <Button data-test="courses-new-btn">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neuer Kurs Neuer Kurs
</Button> </Button>
@@ -104,7 +113,7 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Kursnr.</th> <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">Name</th>
<th className="p-3 text-left font-medium">Beginn</th> <th className="p-3 text-left font-medium">Beginn</th>
@@ -116,7 +125,10 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
</thead> </thead>
<tbody> <tbody>
{courses.data.map((course: Record<string, unknown>) => ( {courses.data.map((course: Record<string, unknown>) => (
<tr key={String(course.id)} className="border-b hover:bg-muted/30"> <tr
key={String(course.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-mono text-xs"> <td className="p-3 font-mono text-xs">
{String(course.course_number ?? '—')} {String(course.course_number ?? '—')}
</td> </td>
@@ -129,20 +141,20 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
</Link> </Link>
</td> </td>
<td className="p-3"> <td className="p-3">
{course.start_date {formatDate(course.start_date as string)}
? new Date(String(course.start_date)).toLocaleDateString('de-DE')
: '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
{course.end_date {formatDate(course.end_date as string)}
? new Date(String(course.end_date)).toLocaleDateString('de-DE')
: '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
<Badge <Badge
variant={COURSE_STATUS_VARIANT[String(course.status)] ?? 'secondary'} variant={
COURSE_STATUS_VARIANT[String(course.status)] ??
'secondary'
}
> >
{COURSE_STATUS_LABEL[String(course.status)] ?? String(course.status)} {COURSE_STATUS_LABEL[String(course.status)] ??
String(course.status)}
</Badge> </Badge>
</td> </td>
<td className="p-3 text-right"> <td className="p-3 text-right">
@@ -164,7 +176,7 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between border-t px-2 py-4"> <div className="flex items-center justify-between border-t px-2 py-4">
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
Seite {page} von {totalPages} ({courses.total} Einträge) Seite {page} von {totalPages} ({courses.total} Einträge)
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -1,14 +1,19 @@
import { GraduationCap, Users, Calendar, TrendingUp, BarChart3 } from 'lucide-react'; import {
GraduationCap,
Users,
Calendar,
TrendingUp,
BarChart3,
} from 'lucide-react';
import { createCourseManagementApi } from '@kit/course-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { StatsCard } from '~/components/stats-card'; import { StatsCard } from '~/components/stats-card';
import { StatsBarChart, StatsPieChart } from '~/components/stats-charts'; import { StatsBarChart, StatsPieChart } from '~/components/stats-charts';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -18,7 +23,11 @@ export default async function CourseStatisticsPage({ params }: PageProps) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client); const api = createCourseManagementApi(client);
@@ -34,10 +43,26 @@ export default async function CourseStatisticsPage({ params }: PageProps) {
<CmsPageShell account={account} title="Kurs-Statistiken"> <CmsPageShell account={account} title="Kurs-Statistiken">
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <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
<StatsCard title="Aktive Kurse" value={stats.openCourses} icon={<Calendar className="h-5 w-5" />} /> title="Kurse gesamt"
<StatsCard title="Teilnehmer" value={stats.totalParticipants} icon={<Users className="h-5 w-5" />} /> value={stats.totalCourses}
<StatsCard title="Abgeschlossen" value={stats.completedCourses} icon={<TrendingUp className="h-5 w-5" />} /> icon={<GraduationCap className="h-5 w-5" />}
/>
<StatsCard
title="Aktive Kurse"
value={stats.openCourses}
icon={<Calendar className="h-5 w-5" />}
/>
<StatsCard
title="Teilnehmer"
value={stats.totalParticipants}
icon={<Users className="h-5 w-5" />}
/>
<StatsCard
title="Abgeschlossen"
value={stats.completedCourses}
icon={<TrendingUp className="h-5 w-5" />}
/>
</div> </div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">

View File

@@ -68,12 +68,13 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
<select <select
id="documentType" id="documentType"
name="documentType" name="documentType"
data-test="document-type-select"
value={selectedType} value={selectedType}
onChange={(e) => { onChange={(e) => {
setSelectedType(e.target.value); setSelectedType(e.target.value);
setResult(null); setResult(null);
}} }}
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" 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:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
> >
<option value="member-card">Mitgliedsausweis</option> <option value="member-card">Mitgliedsausweis</option>
<option value="invoice">Rechnung</option> <option value="invoice">Rechnung</option>
@@ -92,7 +93,8 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
<p className="font-medium">Demnächst verfügbar</p> <p className="font-medium">Demnächst verfügbar</p>
<p className="mt-1 text-amber-700 dark:text-amber-300"> <p className="mt-1 text-amber-700 dark:text-amber-300">
Die Generierung von &ldquo;{DOCUMENT_LABELS[selectedType]}&rdquo; Die Generierung von &ldquo;{DOCUMENT_LABELS[selectedType]}&rdquo;
befindet sich noch in Entwicklung und wird in Kürze verfügbar sein. befindet sich noch in Entwicklung und wird in Kürze verfügbar
sein.
</p> </p>
</div> </div>
</div> </div>
@@ -118,7 +120,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
id="format" id="format"
name="format" name="format"
disabled={isPending} disabled={isPending}
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 disabled:opacity-50" 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:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-50"
> >
<option value="A4">A4</option> <option value="A4">A4</option>
<option value="A5">A5</option> <option value="A5">A5</option>
@@ -131,7 +133,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
id="orientation" id="orientation"
name="orientation" name="orientation"
disabled={isPending} disabled={isPending}
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 disabled:opacity-50" 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:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-50"
> >
<option value="portrait">Hochformat</option> <option value="portrait">Hochformat</option>
<option value="landscape">Querformat</option> <option value="landscape">Querformat</option>
@@ -140,7 +142,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
</div> </div>
{/* Hint */} {/* Hint */}
<div className="text-muted-foreground rounded-md bg-muted/50 p-4 text-sm"> <div className="text-muted-foreground bg-muted/50 rounded-md p-4 text-sm">
<p> <p>
<strong>Hinweis:</strong>{' '} <strong>Hinweis:</strong>{' '}
{selectedType === 'member-card' {selectedType === 'member-card'
@@ -189,7 +191,11 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
{/* Submit button */} {/* Submit button */}
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" disabled={isPending || isComingSoon}> <Button
type="submit"
data-test="document-generate-btn"
disabled={isPending || isComingSoon}
>
{isPending ? ( {isPending ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -211,11 +217,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
* Trigger a browser download from a base64 string. * Trigger a browser download from a base64 string.
* Uses an anchor element with the download attribute set to the full filename. * Uses an anchor element with the download attribute set to the full filename.
*/ */
function downloadFile( function downloadFile(base64Data: string, mimeType: string, fileName: string) {
base64Data: string,
mimeType: string,
fileName: string,
) {
const byteCharacters = atob(base64Data); const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length); const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) { for (let i = 0; i < byteCharacters.length; i++) {

View File

@@ -2,8 +2,10 @@
import React from 'react'; import React from 'react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createDocumentGeneratorApi } from '@kit/document-generator/api'; import { createDocumentGeneratorApi } from '@kit/document-generator/api';
import { formatDate } from '@kit/shared/dates';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export type GenerateDocumentInput = { export type GenerateDocumentInput = {
accountSlug: string; accountSlug: string;
@@ -55,7 +57,11 @@ export async function generateDocumentAction(
return { success: false, error: 'Unbekannter Dokumenttyp.' }; return { success: false, error: 'Unbekannter Dokumenttyp.' };
} }
} catch (err) { } catch (err) {
console.error('Document generation error:', err); const logger = await getLogger();
logger.error(
{ error: err, context: 'document-generation' },
'Document generation error',
);
return { return {
success: false, success: false,
error: err instanceof Error ? err.message : 'Unbekannter Fehler.', error: err instanceof Error ? err.message : 'Unbekannter Fehler.',
@@ -73,8 +79,7 @@ const LABELS: Record<string, string> = {
}; };
function fmtDate(d: string | null): string { function fmtDate(d: string | null): string {
if (!d) return ''; return formatDate(d);
try { return new Date(d).toLocaleDateString('de-DE'); } catch { return d; }
} }
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@@ -88,16 +93,28 @@ async function generateMemberCards(
): Promise<GenerateDocumentResult> { ): Promise<GenerateDocumentResult> {
const { data: members, error } = await client const { data: members, error } = await client
.from('members') .from('members')
.select('id, member_number, first_name, last_name, entry_date, status, date_of_birth, street, house_number, postal_code, city, email') .select(
'id, member_number, first_name, last_name, entry_date, status, date_of_birth, street, house_number, postal_code, city, email',
)
.eq('account_id', accountId) .eq('account_id', accountId)
.eq('status', 'active') .eq('status', 'active')
.order('last_name'); .order('last_name');
if (error) return { success: false, error: `DB-Fehler: ${error.message}` }; if (error) return { success: false, error: `DB-Fehler: ${error.message}` };
if (!members?.length) return { success: false, error: 'Keine aktiven Mitglieder.' }; if (!members?.length)
return { success: false, error: 'Keine aktiven Mitglieder.' };
const { Document, Page, View, Text, StyleSheet, renderToBuffer, Svg, Rect, Circle } = const {
await import('@react-pdf/renderer'); Document,
Page,
View,
Text,
StyleSheet,
renderToBuffer,
Svg,
Rect,
Circle,
} = await import('@react-pdf/renderer');
// — Brand colors (configurable later via account settings) — // — Brand colors (configurable later via account settings) —
const PRIMARY = '#1e40af'; const PRIMARY = '#1e40af';
@@ -107,7 +124,13 @@ async function generateMemberCards(
const LIGHT_GRAY = '#f1f5f9'; const LIGHT_GRAY = '#f1f5f9';
const s = StyleSheet.create({ const s = StyleSheet.create({
page: { padding: 24, flexDirection: 'row', flexWrap: 'wrap', gap: 16, fontFamily: 'Helvetica' }, page: {
padding: 24,
flexDirection: 'row',
flexWrap: 'wrap',
gap: 16,
fontFamily: 'Helvetica',
},
// ── Card shell ── // ── Card shell ──
card: { card: {
@@ -138,10 +161,22 @@ async function generateMemberCards(
paddingHorizontal: 6, paddingHorizontal: 6,
paddingVertical: 2, paddingVertical: 2,
}, },
badgeText: { fontSize: 6, color: PRIMARY, fontFamily: 'Helvetica-Bold', textTransform: 'uppercase' as const, letterSpacing: 0.8 }, badgeText: {
fontSize: 6,
color: PRIMARY,
fontFamily: 'Helvetica-Bold',
textTransform: 'uppercase' as const,
letterSpacing: 0.8,
},
// ── Main content ── // ── Main content ──
body: { flexDirection: 'row', paddingHorizontal: 14, paddingTop: 8, gap: 12, flex: 1 }, body: {
flexDirection: 'row',
paddingHorizontal: 14,
paddingTop: 8,
gap: 12,
flex: 1,
},
// Photo column // Photo column
photoCol: { width: 64, alignItems: 'center' }, photoCol: { width: 64, alignItems: 'center' },
@@ -165,11 +200,22 @@ async function generateMemberCards(
// Info column // Info column
infoCol: { flex: 1, justifyContent: 'center' }, infoCol: { flex: 1, justifyContent: 'center' },
memberName: { fontSize: 14, fontFamily: 'Helvetica-Bold', color: DARK, marginBottom: 6 }, memberName: {
fontSize: 14,
fontFamily: 'Helvetica-Bold',
color: DARK,
marginBottom: 6,
},
fieldGroup: { flexDirection: 'row', flexWrap: 'wrap', gap: 4 }, fieldGroup: { flexDirection: 'row', flexWrap: 'wrap', gap: 4 },
field: { width: '48%', marginBottom: 5 }, field: { width: '48%', marginBottom: 5 },
fieldLabel: { fontSize: 6, color: GRAY, textTransform: 'uppercase' as const, letterSpacing: 0.6, marginBottom: 1 }, fieldLabel: {
fontSize: 6,
color: GRAY,
textTransform: 'uppercase' as const,
letterSpacing: 0.6,
marginBottom: 1,
},
fieldValue: { fontSize: 8, color: DARK, fontFamily: 'Helvetica-Bold' }, fieldValue: { fontSize: 8, color: DARK, fontFamily: 'Helvetica-Bold' },
// ── Footer ── // ── Footer ──
@@ -184,10 +230,16 @@ async function generateMemberCards(
}, },
footerLeft: { fontSize: 6, color: GRAY }, footerLeft: { fontSize: 6, color: GRAY },
footerRight: { fontSize: 6, color: GRAY }, footerRight: { fontSize: 6, color: GRAY },
validDot: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: '#22c55e', marginRight: 3 }, validDot: {
width: 5,
height: 5,
borderRadius: 2.5,
backgroundColor: '#22c55e',
marginRight: 3,
},
}); });
const today = new Date().toLocaleDateString('de-DE'); const today = formatDate(new Date());
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const cardsPerPage = 4; const cardsPerPage = 4;
const pages: React.ReactElement[] = []; const pages: React.ReactElement[] = [];
@@ -198,52 +250,122 @@ async function generateMemberCards(
pages.push( pages.push(
React.createElement( React.createElement(
Page, Page,
{ key: `p${i}`, size: input.format === 'letter' ? 'LETTER' : (input.format.toUpperCase() as 'A4'|'A5'), orientation: input.orientation, style: s.page }, {
...batch.map((m) => key: `p${i}`,
React.createElement(View, { key: m.id, style: s.card }, size:
input.format === 'letter'
? 'LETTER'
: (input.format.toUpperCase() as 'A4' | 'A5'),
orientation: input.orientation,
style: s.page,
},
...batch.map((memberItem) =>
React.createElement(
View,
{ key: memberItem.id, style: s.card },
// Accent bar // Accent bar
React.createElement(View, { style: s.accentBar }), React.createElement(View, { style: s.accentBar }),
// Header // Header
React.createElement(View, { style: s.header }, React.createElement(
View,
{ style: s.header },
React.createElement(Text, { style: s.clubName }, accountName), React.createElement(Text, { style: s.clubName }, accountName),
React.createElement(View, { style: s.badge }, React.createElement(
React.createElement(Text, { style: s.badgeText }, 'Mitgliedsausweis'), View,
{ style: s.badge },
React.createElement(
Text,
{ style: s.badgeText },
'Mitgliedsausweis',
),
), ),
), ),
// Body: photo + info // Body: photo + info
React.createElement(View, { style: s.body }, React.createElement(
View,
{ style: s.body },
// Photo column // Photo column
React.createElement(View, { style: s.photoCol }, React.createElement(
React.createElement(View, { style: s.photoFrame }, View,
{ style: s.photoCol },
React.createElement(
View,
{ style: s.photoFrame },
React.createElement(Text, { style: s.photoIcon }, '👤'), React.createElement(Text, { style: s.photoIcon }, '👤'),
), ),
React.createElement(Text, { style: s.memberNumber }, `Nr. ${m.member_number ?? ''}`), React.createElement(
Text,
{ style: s.memberNumber },
`Nr. ${memberItem.member_number ?? ''}`,
),
), ),
// Info column // Info column
React.createElement(View, { style: s.infoCol }, React.createElement(
React.createElement(Text, { style: s.memberName }, `${m.first_name} ${m.last_name}`), View,
React.createElement(View, { style: s.fieldGroup }, { style: s.infoCol },
React.createElement(
Text,
{ style: s.memberName },
`${memberItem.first_name} ${memberItem.last_name}`,
),
React.createElement(
View,
{ style: s.fieldGroup },
// Entry date // Entry date
React.createElement(View, { style: s.field }, React.createElement(
React.createElement(Text, { style: s.fieldLabel }, 'Mitglied seit'), View,
React.createElement(Text, { style: s.fieldValue }, fmtDate(m.entry_date)), { style: s.field },
React.createElement(
Text,
{ style: s.fieldLabel },
'Mitglied seit',
),
React.createElement(
Text,
{ style: s.fieldValue },
fmtDate(memberItem.entry_date),
),
), ),
// Date of birth // Date of birth
React.createElement(View, { style: s.field }, React.createElement(
React.createElement(Text, { style: s.fieldLabel }, 'Geb.-Datum'), View,
React.createElement(Text, { style: s.fieldValue }, fmtDate(m.date_of_birth)), { style: s.field },
React.createElement(
Text,
{ style: s.fieldLabel },
'Geb.-Datum',
),
React.createElement(
Text,
{ style: s.fieldValue },
fmtDate(memberItem.date_of_birth),
),
), ),
// Address // Address
React.createElement(View, { style: { ...s.field, width: '100%' } }, React.createElement(
React.createElement(Text, { style: s.fieldLabel }, 'Adresse'), View,
React.createElement(Text, { style: s.fieldValue }, { style: { ...s.field, width: '100%' } },
[m.street, m.house_number].filter(Boolean).join(' ') || '', React.createElement(
Text,
{ style: s.fieldLabel },
'Adresse',
), ),
React.createElement(Text, { style: { ...s.fieldValue, marginTop: 1 } }, React.createElement(
[m.postal_code, m.city].filter(Boolean).join(' ') || '', Text,
{ style: s.fieldValue },
[memberItem.street, memberItem.house_number]
.filter(Boolean)
.join(' ') || '',
),
React.createElement(
Text,
{ style: { ...s.fieldValue, marginTop: 1 } },
[memberItem.postal_code, memberItem.city]
.filter(Boolean)
.join(' ') || '',
), ),
), ),
), ),
@@ -251,12 +373,24 @@ async function generateMemberCards(
), ),
// Footer // Footer
React.createElement(View, { style: s.footer }, React.createElement(
React.createElement(View, { style: { flexDirection: 'row', alignItems: 'center' } }, View,
{ style: s.footer },
React.createElement(
View,
{ style: { flexDirection: 'row', alignItems: 'center' } },
React.createElement(View, { style: s.validDot }), React.createElement(View, { style: s.validDot }),
React.createElement(Text, { style: s.footerLeft }, `Gültig ${year}/${year + 1}`), React.createElement(
Text,
{ style: s.footerLeft },
`Gültig ${year}/${year + 1}`,
),
),
React.createElement(
Text,
{ style: s.footerRight },
`Ausgestellt ${today}`,
), ),
React.createElement(Text, { style: s.footerRight }, `Ausgestellt ${today}`),
), ),
), ),
), ),
@@ -285,19 +419,32 @@ async function generateLabels(
): Promise<GenerateDocumentResult> { ): Promise<GenerateDocumentResult> {
const { data: members, error } = await client const { data: members, error } = await client
.from('members') .from('members')
.select('first_name, last_name, street, house_number, postal_code, city, salutation, title') .select(
'first_name, last_name, street, house_number, postal_code, city, salutation, title',
)
.eq('account_id', accountId) .eq('account_id', accountId)
.eq('status', 'active') .eq('status', 'active')
.order('last_name'); .order('last_name');
if (error) return { success: false, error: `DB-Fehler: ${error.message}` }; if (error) return { success: false, error: `DB-Fehler: ${error.message}` };
if (!members?.length) return { success: false, error: 'Keine aktiven Mitglieder.' }; if (!members?.length)
return { success: false, error: 'Keine aktiven Mitglieder.' };
const api = createDocumentGeneratorApi(); const api = createDocumentGeneratorApi();
const records = members.map((m) => ({ const records = members.map((record) => ({
line1: [m.salutation, m.title, m.first_name, m.last_name].filter(Boolean).join(' '), line1: [
line2: [m.street, m.house_number].filter(Boolean).join(' ') || undefined, record.salutation,
line3: [m.postal_code, m.city].filter(Boolean).join(' ') || undefined, record.title,
record.first_name,
record.last_name,
]
.filter(Boolean)
.join(' '),
line2:
[record.street, record.house_number].filter(Boolean).join(' ') ||
undefined,
line3:
[record.postal_code, record.city].filter(Boolean).join(' ') || undefined,
})); }));
const html = api.generateLabelsHtml({ labelFormat: 'avery-l7163', records }); const html = api.generateLabelsHtml({ labelFormat: 'avery-l7163', records });
@@ -320,7 +467,9 @@ async function generateMemberReport(
): Promise<GenerateDocumentResult> { ): Promise<GenerateDocumentResult> {
const { data: members, error } = await client const { data: members, error } = await client
.from('members') .from('members')
.select('member_number, last_name, first_name, email, postal_code, city, status, entry_date') .select(
'member_number, last_name, first_name, email, postal_code, city, status, entry_date',
)
.eq('account_id', accountId) .eq('account_id', accountId)
.order('last_name'); .order('last_name');
@@ -346,27 +495,42 @@ async function generateMemberReport(
const hdr = ws.getRow(1); const hdr = ws.getRow(1);
hdr.font = { bold: true, color: { argb: 'FFFFFFFF' } }; hdr.font = { bold: true, color: { argb: 'FFFFFFFF' } };
hdr.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E40AF' } }; hdr.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF1E40AF' },
};
hdr.alignment = { vertical: 'middle', horizontal: 'center' }; hdr.alignment = { vertical: 'middle', horizontal: 'center' };
hdr.height = 24; hdr.height = 24;
const SL: Record<string, string> = { active: 'Aktiv', inactive: 'Inaktiv', pending: 'Ausstehend', resigned: 'Ausgetreten', excluded: 'Ausgeschlossen' }; const SL: Record<string, string> = {
active: 'Aktiv',
inactive: 'Inaktiv',
pending: 'Ausstehend',
resigned: 'Ausgetreten',
excluded: 'Ausgeschlossen',
};
for (const m of members) { for (const member of members) {
ws.addRow({ ws.addRow({
nr: m.member_number ?? '', nr: member.member_number ?? '',
name: m.last_name, name: member.last_name,
vorname: m.first_name, vorname: member.first_name,
email: m.email ?? '', email: member.email ?? '',
plz: m.postal_code ?? '', plz: member.postal_code ?? '',
ort: m.city ?? '', ort: member.city ?? '',
status: SL[m.status] ?? m.status, status: SL[member.status] ?? member.status,
eintritt: m.entry_date ? new Date(m.entry_date).toLocaleDateString('de-DE') : '', eintritt: member.entry_date ? formatDate(member.entry_date) : '',
}); });
} }
ws.eachRow((row, n) => { ws.eachRow((row, n) => {
if (n > 1 && n % 2 === 0) row.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF1F5F9' } }; if (n > 1 && n % 2 === 0)
row.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFF1F5F9' },
};
row.border = { bottom: { style: 'thin', color: { argb: 'FFE2E8F0' } } }; row.border = { bottom: { style: 'thin', color: { argb: 'FFE2E8F0' } } };
}); });
@@ -379,7 +543,8 @@ async function generateMemberReport(
return { return {
success: true, success: true,
data: Buffer.from(buf as ArrayBuffer).toString('base64'), data: Buffer.from(buf as ArrayBuffer).toString('base64'),
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', mimeType:
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
fileName: `${input.title || 'Mitgliederbericht'}.xlsx`, fileName: `${input.title || 'Mitgliederbericht'}.xlsx`,
}; };
} }

View File

@@ -13,10 +13,10 @@ import {
CardTitle, CardTitle,
} from '@kit/ui/card'; } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { GenerateDocumentForm } from '../_components/generate-document-form'; import { GenerateDocumentForm } from '../_components/generate-document-form';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;

View File

@@ -13,8 +13,8 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -40,32 +40,28 @@ const DOCUMENT_TYPES = [
{ {
id: 'labels', id: 'labels',
title: 'Etiketten', title: 'Etiketten',
description: description: 'Adressetiketten für Serienbriefe im Avery-Format drucken.',
'Adressetiketten für Serienbriefe im Avery-Format drucken.',
icon: Tag, icon: Tag,
color: 'text-orange-600 bg-orange-50', color: 'text-orange-600 bg-orange-50',
}, },
{ {
id: 'report', id: 'report',
title: 'Bericht', title: 'Bericht',
description: description: 'Statistische Auswertungen und Berichte als PDF oder Excel.',
'Statistische Auswertungen und Berichte als PDF oder Excel.',
icon: BarChart3, icon: BarChart3,
color: 'text-purple-600 bg-purple-50', color: 'text-purple-600 bg-purple-50',
}, },
{ {
id: 'letter', id: 'letter',
title: 'Brief', title: 'Brief',
description: description: 'Serienbriefe mit personalisierten Platzhaltern erstellen.',
'Serienbriefe mit personalisierten Platzhaltern erstellen.',
icon: Mail, icon: Mail,
color: 'text-rose-600 bg-rose-50', color: 'text-rose-600 bg-rose-50',
}, },
{ {
id: 'certificate', id: 'certificate',
title: 'Zertifikat', title: 'Zertifikat',
description: description: 'Teilnahmebescheinigungen und Zertifikate mit Unterschrift.',
'Teilnahmebescheinigungen und Zertifikate mit Unterschrift.',
icon: Award, icon: Award,
color: 'text-amber-600 bg-amber-50', color: 'text-amber-600 bg-amber-50',
}, },
@@ -84,7 +80,11 @@ export default async function DocumentsPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell account={account} title="Dokumente" description="Dokumente erstellen und verwalten"> <CmsPageShell
account={account}
title="Dokumente"
description="Dokumente erstellen und verwalten"
>
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
{/* Actions */} {/* Actions */}
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
@@ -108,7 +108,7 @@ export default async function DocumentsPage({ params }: PageProps) {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-1 flex-col justify-between gap-4"> <CardContent className="flex flex-1 flex-col justify-between gap-4">
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
{docType.description} {docType.description}
</p> </p>
<Link <Link

View File

@@ -6,9 +6,9 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -46,7 +46,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
</p> </p>
</div> </div>
<Button> <Button data-test="document-templates-new-btn">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neue Vorlage Neue Vorlage
</Button> </Button>
@@ -69,7 +69,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Name</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-left font-medium">Typ</th>
<th className="p-3 text-left font-medium"> <th className="p-3 text-left font-medium">
@@ -81,11 +81,11 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
{templates.map((template) => ( {templates.map((template) => (
<tr <tr
key={template.id} key={template.id}
className="border-b hover:bg-muted/30" className="hover:bg-muted/30 border-b"
> >
<td className="p-3 font-medium">{template.name}</td> <td className="p-3 font-medium">{template.name}</td>
<td className="p-3">{template.type}</td> <td className="p-3">{template.type}</td>
<td className="p-3 text-muted-foreground"> <td className="text-muted-foreground p-3">
{template.description} {template.description}
</td> </td>
</tr> </tr>

View File

@@ -9,13 +9,13 @@ import {
UserPlus, UserPlus,
} from 'lucide-react'; } from 'lucide-react';
import { createEventManagementApi } from '@kit/event-management/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createEventManagementApi } from '@kit/event-management/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
@@ -32,7 +32,10 @@ const STATUS_LABEL: Record<string, string> = {
completed: 'Abgeschlossen', completed: 'Abgeschlossen',
}; };
const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'outline' | 'destructive'> = { const STATUS_VARIANT: Record<
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
draft: 'secondary', draft: 'secondary',
published: 'default', published: 'default',
registration_open: 'info', registration_open: 'info',
@@ -53,17 +56,21 @@ export default async function EventDetailPage({ params }: PageProps) {
if (!event) return <div>Veranstaltung nicht gefunden</div>; if (!event) return <div>Veranstaltung nicht gefunden</div>;
const e = event as Record<string, unknown>; const eventData = event as Record<string, unknown>;
return ( return (
<CmsPageShell account={account} title={String(e.name)}> <CmsPageShell account={account} title={String(eventData.name)}>
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">{String(e.name)}</h1> <h1 className="text-2xl font-bold">{String(eventData.name)}</h1>
<Badge variant={STATUS_VARIANT[String(e.status)] ?? 'secondary'} className="mt-1"> <Badge
{STATUS_LABEL[String(e.status)] ?? String(e.status)} variant={STATUS_VARIANT[String(eventData.status)] ?? 'secondary'}
className="mt-1"
>
{STATUS_LABEL[String(eventData.status)] ??
String(eventData.status)}
</Badge> </Badge>
</div> </div>
<Button> <Button>
@@ -76,44 +83,45 @@ export default async function EventDetailPage({ params }: PageProps) {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card> <Card>
<CardContent className="flex items-center gap-3 p-4"> <CardContent className="flex items-center gap-3 p-4">
<CalendarDays className="h-5 w-5 text-primary" /> <CalendarDays className="text-primary h-5 w-5" />
<div> <div>
<p className="text-xs text-muted-foreground">Datum</p> <p className="text-muted-foreground text-xs">Datum</p>
<p className="font-semibold"> <p className="font-semibold">
{e.event_date {formatDate(eventData.event_date as string)}
? new Date(String(e.event_date)).toLocaleDateString('de-DE')
: '—'}
</p> </p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="flex items-center gap-3 p-4"> <CardContent className="flex items-center gap-3 p-4">
<Clock className="h-5 w-5 text-primary" /> <Clock className="text-primary h-5 w-5" />
<div> <div>
<p className="text-xs text-muted-foreground">Uhrzeit</p> <p className="text-muted-foreground text-xs">Uhrzeit</p>
<p className="font-semibold"> <p className="font-semibold">
{String(e.start_time ?? '—')} {String(e.end_time ?? '—')} {String(eventData.start_time ?? '—')} {' '}
{String(eventData.end_time ?? '—')}
</p> </p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="flex items-center gap-3 p-4"> <CardContent className="flex items-center gap-3 p-4">
<MapPin className="h-5 w-5 text-primary" /> <MapPin className="text-primary h-5 w-5" />
<div> <div>
<p className="text-xs text-muted-foreground">Ort</p> <p className="text-muted-foreground text-xs">Ort</p>
<p className="font-semibold">{String(e.location ?? '—')}</p> <p className="font-semibold">
{String(eventData.location ?? '—')}
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="flex items-center gap-3 p-4"> <CardContent className="flex items-center gap-3 p-4">
<Users className="h-5 w-5 text-primary" /> <Users className="text-primary h-5 w-5" />
<div> <div>
<p className="text-xs text-muted-foreground">Anmeldungen</p> <p className="text-muted-foreground text-xs">Anmeldungen</p>
<p className="font-semibold"> <p className="font-semibold">
{registrations.length} / {String(e.capacity ?? '∞')} {registrations.length} / {String(eventData.capacity ?? '∞')}
</p> </p>
</div> </div>
</CardContent> </CardContent>
@@ -121,14 +129,14 @@ export default async function EventDetailPage({ params }: PageProps) {
</div> </div>
{/* Description */} {/* Description */}
{e.description ? ( {eventData.description ? (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Beschreibung</CardTitle> <CardTitle>Beschreibung</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground whitespace-pre-wrap"> <p className="text-muted-foreground text-sm whitespace-pre-wrap">
{String(e.description)} {String(eventData.description)}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -141,14 +149,14 @@ export default async function EventDetailPage({ params }: PageProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{registrations.length === 0 ? ( {registrations.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground"> <p className="text-muted-foreground py-6 text-center text-sm">
Noch keine Anmeldungen Noch keine Anmeldungen
</p> </p>
) : ( ) : (
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Name</th> <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">E-Mail</th>
<th className="p-3 text-left font-medium">Elternteil</th> <th className="p-3 text-left font-medium">Elternteil</th>
@@ -157,16 +165,20 @@ export default async function EventDetailPage({ params }: PageProps) {
</thead> </thead>
<tbody> <tbody>
{registrations.map((reg: Record<string, unknown>) => ( {registrations.map((reg: Record<string, unknown>) => (
<tr key={String(reg.id)} className="border-b hover:bg-muted/30"> <tr
key={String(reg.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium"> <td className="p-3 font-medium">
{String(reg.last_name ?? '')}, {String(reg.first_name ?? '')} {String(reg.last_name ?? '')},{' '}
{String(reg.first_name ?? '')}
</td> </td>
<td className="p-3">{String(reg.email ?? '—')}</td> <td className="p-3">{String(reg.email ?? '—')}</td>
<td className="p-3">{String(reg.parent_name ?? '—')}</td>
<td className="p-3"> <td className="p-3">
{reg.created_at {String(reg.parent_name ?? '—')}
? new Date(String(reg.created_at)).toLocaleDateString('de-DE') </td>
: '—'} <td className="p-3">
{formatDate(reg.created_at as string)}
</td> </td>
</tr> </tr>
))} ))}

View File

@@ -1,15 +1,15 @@
import { Ticket, Plus } from 'lucide-react'; import { Ticket, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { createEventManagementApi } from '@kit/event-management/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createEventManagementApi } from '@kit/event-management/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -37,7 +37,9 @@ export default async function HolidayPassesPage({ params }: PageProps) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">{t('holidayPasses')}</h1> <h1 className="text-2xl font-bold">{t('holidayPasses')}</h1>
<p className="text-muted-foreground">{t('holidayPassesDescription')}</p> <p className="text-muted-foreground">
{t('holidayPassesDescription')}
</p>
</div> </div>
<Button> <Button>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
@@ -55,23 +57,34 @@ export default async function HolidayPassesPage({ params }: PageProps) {
) : ( ) : (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('allHolidayPasses')} ({passes.length})</CardTitle> <CardTitle>
{t('allHolidayPasses')} ({passes.length})
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">{t('name')}</th> <th className="p-3 text-left font-medium">{t('name')}</th>
<th className="p-3 text-left font-medium">{t('year')}</th> <th className="p-3 text-left font-medium">{t('year')}</th>
<th className="p-3 text-right font-medium">{t('price')}</th> <th className="p-3 text-right font-medium">
<th className="p-3 text-left font-medium">{t('validFrom')}</th> {t('price')}
<th className="p-3 text-left font-medium">{t('validUntil')}</th> </th>
<th className="p-3 text-left font-medium">
{t('validFrom')}
</th>
<th className="p-3 text-left font-medium">
{t('validUntil')}
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{passes.map((pass: Record<string, unknown>) => ( {passes.map((pass: Record<string, unknown>) => (
<tr key={String(pass.id)} className="border-b hover:bg-muted/30"> <tr
key={String(pass.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">{String(pass.name)}</td> <td className="p-3 font-medium">{String(pass.name)}</td>
<td className="p-3">{String(pass.year ?? '—')}</td> <td className="p-3">{String(pass.year ?? '—')}</td>
<td className="p-3 text-right"> <td className="p-3 text-right">
@@ -80,14 +93,10 @@ export default async function HolidayPassesPage({ params }: PageProps) {
: '—'} : '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
{pass.valid_from {formatDate(pass.valid_from as string)}
? new Date(String(pass.valid_from)).toLocaleDateString('de-DE')
: '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
{pass.valid_until {formatDate(pass.valid_until as string)}
? new Date(String(pass.valid_until)).toLocaleDateString('de-DE')
: '—'}
</td> </td>
</tr> </tr>
))} ))}

View File

@@ -1,20 +1,32 @@
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateEventForm } from '@kit/event-management/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { params: Promise<{ account: string }> } import { CreateEventForm } from '@kit/event-management/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string }>;
}
export default async function NewEventPage({ params }: Props) { export default async function NewEventPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const t = await getTranslations('cms.events'); const t = await getTranslations('cms.events');
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell account={account} title={t('newEvent')} description={t('newEventDescription')}> <CmsPageShell
account={account}
title={t('newEvent')}
description={t('newEventDescription')}
>
<CreateEventForm accountId={acct.id} account={account} /> <CreateEventForm accountId={acct.id} account={account} />
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -1,19 +1,26 @@
import Link from 'next/link'; import Link from 'next/link';
import { CalendarDays, ChevronLeft, ChevronRight, MapPin, Plus, Users } from 'lucide-react'; import {
CalendarDays,
ChevronLeft,
ChevronRight,
MapPin,
Plus,
Users,
} from 'lucide-react';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { createEventManagementApi } from '@kit/event-management/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createEventManagementApi } from '@kit/event-management/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card'; import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
import { EVENT_STATUS_VARIANT, EVENT_STATUS_LABEL } from '~/lib/status-badges'; import { EVENT_STATUS_VARIANT, EVENT_STATUS_LABEL } from '~/lib/status-badges';
interface PageProps { interface PageProps {
@@ -40,19 +47,21 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
const events = await api.listEvents(acct.id, { page }); const events = await api.listEvents(acct.id, { page });
// Fetch registration counts for all events on this page // Fetch registration counts for all events on this page
const eventIds = events.data.map((e: Record<string, unknown>) => String(e.id)); const eventIds = events.data.map((eventItem: Record<string, unknown>) =>
String(eventItem.id),
);
const registrationCounts = await api.getRegistrationCounts(eventIds); const registrationCounts = await api.getRegistrationCounts(eventIds);
// Pre-compute stats before rendering // Pre-compute stats before rendering
const uniqueLocationCount = new Set( const uniqueLocationCount = new Set(
events.data events.data
.map((e: Record<string, unknown>) => e.location) .map((eventItem: Record<string, unknown>) => eventItem.location)
.filter(Boolean), .filter(Boolean),
).size; ).size;
const totalCapacity = events.data.reduce( const totalCapacity = events.data.reduce(
(sum: number, e: Record<string, unknown>) => (sum: number, eventItem: Record<string, unknown>) =>
sum + (Number(e.capacity) || 0), sum + (Number(eventItem.capacity) || 0),
0, 0,
); );
@@ -63,13 +72,11 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">{t('title')}</h1> <h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">{t('description')}</p>
{t('description')}
</p>
</div> </div>
<Link href={`/home/${account}/events/new`}> <Link href={`/home/${account}/events/new`}>
<Button> <Button data-test="events-new-btn">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
{t('newEvent')} {t('newEvent')}
</Button> </Button>
@@ -107,19 +114,31 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
) : ( ) : (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('allEvents')} ({events.total})</CardTitle> <CardTitle>
{t('allEvents')} ({events.total})
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">{t('name')}</th> <th className="p-3 text-left font-medium">{t('name')}</th>
<th className="p-3 text-left font-medium">{t('eventDate')}</th> <th className="p-3 text-left font-medium">
<th className="p-3 text-left font-medium">{t('eventLocation')}</th> {t('eventDate')}
<th className="p-3 text-right font-medium">{t('capacity')}</th> </th>
<th className="p-3 text-left font-medium">{t('status')}</th> <th className="p-3 text-left font-medium">
<th className="p-3 text-right font-medium">{t('registrations')}</th> {t('eventLocation')}
</th>
<th className="p-3 text-right font-medium">
{t('capacity')}
</th>
<th className="p-3 text-left font-medium">
{t('status')}
</th>
<th className="p-3 text-right font-medium">
{t('registrations')}
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -130,7 +149,7 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
return ( return (
<tr <tr
key={eventId} key={eventId}
className="border-b hover:bg-muted/30" className="hover:bg-muted/30 border-b"
> >
<td className="p-3 font-medium"> <td className="p-3 font-medium">
<Link <Link
@@ -141,9 +160,7 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
</Link> </Link>
</td> </td>
<td className="p-3"> <td className="p-3">
{event.event_date {formatDate(event.event_date as string)}
? new Date(String(event.event_date)).toLocaleDateString('de-DE')
: '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
{String(event.location ?? '—')} {String(event.location ?? '—')}
@@ -156,10 +173,12 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
<td className="p-3"> <td className="p-3">
<Badge <Badge
variant={ variant={
EVENT_STATUS_VARIANT[String(event.status)] ?? 'secondary' EVENT_STATUS_VARIANT[String(event.status)] ??
'secondary'
} }
> >
{EVENT_STATUS_LABEL[String(event.status)] ?? String(event.status)} {EVENT_STATUS_LABEL[String(event.status)] ??
String(event.status)}
</Badge> </Badge>
</td> </td>
<td className="p-3 text-right font-medium"> <td className="p-3 text-right font-medium">
@@ -175,12 +194,17 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
{/* Pagination */} {/* Pagination */}
{events.totalPages > 1 && ( {events.totalPages > 1 && (
<div className="flex items-center justify-between pt-4"> <div className="flex items-center justify-between pt-4">
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground text-sm">
{t('paginationPage', { page: events.page, totalPages: events.totalPages })} {t('paginationPage', {
page: events.page,
totalPages: events.totalPages,
})}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
{events.page > 1 && ( {events.page > 1 && (
<Link href={`/home/${account}/events?page=${events.page - 1}`}> <Link
href={`/home/${account}/events?page=${events.page - 1}`}
>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<ChevronLeft className="mr-1 h-4 w-4" /> <ChevronLeft className="mr-1 h-4 w-4" />
{t('paginationPrevious')} {t('paginationPrevious')}
@@ -188,7 +212,9 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
</Link> </Link>
)} )}
{events.page < events.totalPages && ( {events.page < events.totalPages && (
<Link href={`/home/${account}/events?page=${events.page + 1}`}> <Link
href={`/home/${account}/events?page=${events.page + 1}`}
>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
{t('paginationNext')} {t('paginationNext')}
<ChevronRight className="ml-1 h-4 w-4" /> <ChevronRight className="ml-1 h-4 w-4" />

View File

@@ -1,18 +1,18 @@
import Link from 'next/link'; import Link from 'next/link';
import { CalendarDays, ClipboardList, Users } from 'lucide-react'; import { CalendarDays, ClipboardList, Users } from 'lucide-react';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { createEventManagementApi } from '@kit/event-management/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createEventManagementApi } from '@kit/event-management/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card'; import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
import { EVENT_STATUS_VARIANT, EVENT_STATUS_LABEL } from '~/lib/status-badges'; import { EVENT_STATUS_VARIANT, EVENT_STATUS_LABEL } from '~/lib/status-badges';
interface PageProps { interface PageProps {
@@ -64,9 +64,7 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-2xl font-bold">{t('registrations')}</h1> <h1 className="text-2xl font-bold">{t('registrations')}</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">{t('registrationsOverview')}</p>
{t('registrationsOverview')}
</p>
</div> </div>
{/* Stats */} {/* Stats */}
@@ -108,17 +106,25 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium"> <th className="p-3 text-left font-medium">
{t('event')} {t('event')}
</th> </th>
<th className="p-3 text-left font-medium">{t('eventDate')}</th> <th className="p-3 text-left font-medium">
<th className="p-3 text-left font-medium">{t('status')}</th> {t('eventDate')}
<th className="p-3 text-right font-medium">{t('capacity')}</th> </th>
<th className="p-3 text-left font-medium">
{t('status')}
</th>
<th className="p-3 text-right font-medium">
{t('capacity')}
</th>
<th className="p-3 text-right font-medium"> <th className="p-3 text-right font-medium">
{t('registrations')} {t('registrations')}
</th> </th>
<th className="p-3 text-right font-medium">{t('utilization')}</th> <th className="p-3 text-right font-medium">
{t('utilization')}
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -133,7 +139,7 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
return ( return (
<tr <tr
key={event.id} key={event.id}
className="border-b hover:bg-muted/30" className="hover:bg-muted/30 border-b"
> >
<td className="p-3 font-medium"> <td className="p-3 font-medium">
<Link <Link
@@ -143,17 +149,12 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
{event.name} {event.name}
</Link> </Link>
</td> </td>
<td className="p-3"> <td className="p-3">{formatDate(event.eventDate)}</td>
{event.eventDate
? new Date(event.eventDate).toLocaleDateString(
'de-DE',
)
: '—'}
</td>
<td className="p-3"> <td className="p-3">
<Badge <Badge
variant={ variant={
EVENT_STATUS_VARIANT[event.status] ?? 'secondary' EVENT_STATUS_VARIANT[event.status] ??
'secondary'
} }
> >
{EVENT_STATUS_LABEL[event.status] ?? event.status} {EVENT_STATUS_LABEL[event.status] ?? event.status}

View File

@@ -2,15 +2,15 @@ import Link from 'next/link';
import { ArrowLeft, Send, CheckCircle } from 'lucide-react'; import { ArrowLeft, Send, CheckCircle } from 'lucide-react';
import { createFinanceApi } from '@kit/finance/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createFinanceApi } from '@kit/finance/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps { interface PageProps {
params: Promise<{ account: string; id: string }>; params: Promise<{ account: string; id: string }>;
@@ -55,7 +55,7 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
const api = createFinanceApi(client); const api = createFinanceApi(client);
const invoice = await api.getInvoiceWithItems(id); const invoice = await api.getInvoiceWithItems(id);
if (!invoice) return <div>Rechnung nicht gefunden</div>; if (!invoice) return <AccountNotFound />;
const status = String(invoice.status); const status = String(invoice.status);
const items = (invoice.items ?? []) as Array<Record<string, unknown>>; const items = (invoice.items ?? []) as Array<Record<string, unknown>>;
@@ -87,7 +87,7 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
<CardContent> <CardContent>
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-muted-foreground text-sm font-medium">
Empfänger Empfänger
</dt> </dt>
<dd className="mt-1 text-sm font-semibold"> <dd className="mt-1 text-sm font-semibold">
@@ -95,31 +95,23 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-muted-foreground text-sm font-medium">
Rechnungsdatum Rechnungsdatum
</dt> </dt>
<dd className="mt-1 text-sm font-semibold"> <dd className="mt-1 text-sm font-semibold">
{invoice.issue_date {formatDate(invoice.issue_date)}
? new Date(
String(invoice.issue_date),
).toLocaleDateString('de-DE')
: '—'}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-muted-foreground text-sm font-medium">
Fälligkeitsdatum Fälligkeitsdatum
</dt> </dt>
<dd className="mt-1 text-sm font-semibold"> <dd className="mt-1 text-sm font-semibold">
{invoice.due_date {formatDate(invoice.due_date)}
? new Date(String(invoice.due_date)).toLocaleDateString(
'de-DE',
)
: '—'}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-muted-foreground text-sm font-medium">
Gesamtbetrag Gesamtbetrag
</dt> </dt>
<dd className="mt-1 text-sm font-semibold"> <dd className="mt-1 text-sm font-semibold">
@@ -155,14 +147,14 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{items.length === 0 ? ( {items.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground"> <p className="text-muted-foreground py-8 text-center text-sm">
Keine Positionen vorhanden. Keine Positionen vorhanden.
</p> </p>
) : ( ) : (
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium"> <th className="p-3 text-left font-medium">
Beschreibung Beschreibung
</th> </th>
@@ -177,7 +169,7 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
{items.map((item) => ( {items.map((item) => (
<tr <tr
key={String(item.id)} key={String(item.id)}
className="border-b hover:bg-muted/30" className="hover:bg-muted/30 border-b"
> >
<td className="p-3"> <td className="p-3">
{String(item.description ?? '—')} {String(item.description ?? '—')}
@@ -199,7 +191,7 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
))} ))}
</tbody> </tbody>
<tfoot> <tfoot>
<tr className="border-t bg-muted/30"> <tr className="bg-muted/30 border-t">
<td colSpan={3} className="p-3 text-right font-medium"> <td colSpan={3} className="p-3 text-right font-medium">
Zwischensumme Zwischensumme
</td> </td>

View File

@@ -1,18 +1,29 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateInvoiceForm } from '@kit/finance/components'; import { CreateInvoiceForm } from '@kit/finance/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { params: Promise<{ account: string }> } import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string }>;
}
export default async function NewInvoicePage({ params }: Props) { export default async function NewInvoicePage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell account={account} title="Neue Rechnung" description="Rechnung mit Positionen erstellen"> <CmsPageShell
account={account}
title="Neue Rechnung"
description="Rechnung mit Positionen erstellen"
>
<CreateInvoiceForm accountId={acct.id} account={account} /> <CreateInvoiceForm accountId={acct.id} account={account} />
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -2,16 +2,16 @@ import Link from 'next/link';
import { FileText, Plus } from 'lucide-react'; import { FileText, Plus } from 'lucide-react';
import { createFinanceApi } from '@kit/finance/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createFinanceApi } from '@kit/finance/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
import { import {
INVOICE_STATUS_VARIANT, INVOICE_STATUS_VARIANT,
INVOICE_STATUS_LABEL, INVOICE_STATUS_LABEL,
@@ -77,7 +77,7 @@ export default async function InvoicesPage({ params }: PageProps) {
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Nr.</th> <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">Empfänger</th>
<th className="p-3 text-left font-medium">Datum</th> <th className="p-3 text-left font-medium">Datum</th>
@@ -92,7 +92,7 @@ export default async function InvoicesPage({ params }: PageProps) {
return ( return (
<tr <tr
key={String(invoice.id)} key={String(invoice.id)}
className="border-b hover:bg-muted/30" className="hover:bg-muted/30 border-b"
> >
<td className="p-3 font-mono text-xs"> <td className="p-3 font-mono text-xs">
<Link <Link
@@ -106,18 +106,10 @@ export default async function InvoicesPage({ params }: PageProps) {
{String(invoice.recipient_name ?? '—')} {String(invoice.recipient_name ?? '—')}
</td> </td>
<td className="p-3"> <td className="p-3">
{invoice.issue_date {formatDate(invoice.issue_date)}
? new Date(
String(invoice.issue_date),
).toLocaleDateString('de-DE')
: '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
{invoice.due_date {formatDate(invoice.due_date)}
? new Date(
String(invoice.due_date),
).toLocaleDateString('de-DE')
: '—'}
</td> </td>
<td className="p-3 text-right"> <td className="p-3 text-right">
{invoice.total_amount != null {invoice.total_amount != null

View File

@@ -2,17 +2,17 @@ import Link from 'next/link';
import { Landmark, FileText, Euro, ArrowRight, Plus } from 'lucide-react'; import { Landmark, FileText, Euro, ArrowRight, Plus } from 'lucide-react';
import { createFinanceApi } from '@kit/finance/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createFinanceApi } from '@kit/finance/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card'; import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
import { import {
BATCH_STATUS_VARIANT, BATCH_STATUS_VARIANT,
BATCH_STATUS_LABEL, BATCH_STATUS_LABEL,
@@ -61,9 +61,7 @@ export default async function FinancePage({ params }: PageProps) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">Finanzen</h1> <h1 className="text-2xl font-bold">Finanzen</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">SEPA-Einzüge und Rechnungen</p>
SEPA-Einzüge und Rechnungen
</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Link href={`/home/${account}/finance/invoices/new`}> <Link href={`/home/${account}/finance/invoices/new`}>
@@ -124,7 +122,7 @@ export default async function FinancePage({ params }: PageProps) {
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Status</th> <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">Typ</th>
<th className="p-3 text-right font-medium">Betrag</th> <th className="p-3 text-right font-medium">Betrag</th>
@@ -135,15 +133,17 @@ export default async function FinancePage({ params }: PageProps) {
{batches.map((batch: Record<string, unknown>) => ( {batches.map((batch: Record<string, unknown>) => (
<tr <tr
key={String(batch.id)} key={String(batch.id)}
className="border-b hover:bg-muted/30" className="hover:bg-muted/30 border-b"
> >
<td className="p-3"> <td className="p-3">
<Badge <Badge
variant={ variant={
BATCH_STATUS_VARIANT[String(batch.status)] ?? 'secondary' BATCH_STATUS_VARIANT[String(batch.status)] ??
'secondary'
} }
> >
{BATCH_STATUS_LABEL[String(batch.status)] ?? String(batch.status)} {BATCH_STATUS_LABEL[String(batch.status)] ??
String(batch.status)}
</Badge> </Badge>
</td> </td>
<td className="p-3"> <td className="p-3">
@@ -157,11 +157,7 @@ export default async function FinancePage({ params }: PageProps) {
: '—'} : '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
{batch.execution_date {formatDate(batch.execution_date ?? batch.created_at)}
? new Date(String(batch.execution_date)).toLocaleDateString('de-DE')
: batch.created_at
? new Date(String(batch.created_at)).toLocaleDateString('de-DE')
: '—'}
</td> </td>
</tr> </tr>
))} ))}
@@ -196,7 +192,7 @@ export default async function FinancePage({ params }: PageProps) {
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Nr.</th> <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">Empfänger</th>
<th className="p-3 text-right font-medium">Betrag</th> <th className="p-3 text-right font-medium">Betrag</th>
@@ -207,7 +203,7 @@ export default async function FinancePage({ params }: PageProps) {
{invoices.map((invoice: Record<string, unknown>) => ( {invoices.map((invoice: Record<string, unknown>) => (
<tr <tr
key={String(invoice.id)} key={String(invoice.id)}
className="border-b hover:bg-muted/30" className="hover:bg-muted/30 border-b"
> >
<td className="p-3 font-mono text-xs"> <td className="p-3 font-mono text-xs">
<Link <Link

View File

@@ -2,16 +2,15 @@ import Link from 'next/link';
import { Euro, CreditCard, TrendingUp, ArrowRight } from 'lucide-react'; import { Euro, CreditCard, TrendingUp, ArrowRight } from 'lucide-react';
import { createFinanceApi } from '@kit/finance/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createFinanceApi } from '@kit/finance/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { StatsCard } from '~/components/stats-card'; import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -120,12 +119,14 @@ export default async function PaymentsPage({ params }: PageProps) {
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Offene Rechnungen</CardTitle> <CardTitle className="text-base">Offene Rechnungen</CardTitle>
<Badge variant={openInvoices.length > 0 ? 'default' : 'secondary'}> <Badge
variant={openInvoices.length > 0 ? 'default' : 'secondary'}
>
{openInvoices.length} {openInvoices.length}
</Badge> </Badge>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="mb-4 text-sm text-muted-foreground"> <p className="text-muted-foreground mb-4 text-sm">
{openInvoices.length > 0 {openInvoices.length > 0
? `${openInvoices.length} Rechnungen mit einem Gesamtbetrag von ${formatCurrency(openTotal)} sind offen.` ? `${openInvoices.length} Rechnungen mit einem Gesamtbetrag von ${formatCurrency(openTotal)} sind offen.`
: 'Keine offenen Rechnungen vorhanden.'} : 'Keine offenen Rechnungen vorhanden.'}
@@ -147,7 +148,7 @@ export default async function PaymentsPage({ params }: PageProps) {
</Badge> </Badge>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="mb-4 text-sm text-muted-foreground"> <p className="text-muted-foreground mb-4 text-sm">
{batches.length > 0 {batches.length > 0
? `${batches.length} SEPA-Einzüge mit einem Gesamtvolumen von ${formatCurrency(sepaTotal)}.` ? `${batches.length} SEPA-Einzüge mit einem Gesamtvolumen von ${formatCurrency(sepaTotal)}.`
: 'Keine SEPA-Einzüge vorhanden.'} : 'Keine SEPA-Einzüge vorhanden.'}

View File

@@ -2,15 +2,15 @@ import Link from 'next/link';
import { ArrowLeft, Download } from 'lucide-react'; import { ArrowLeft, Download } from 'lucide-react';
import { createFinanceApi } from '@kit/finance/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createFinanceApi } from '@kit/finance/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps { interface PageProps {
params: Promise<{ account: string; batchId: string }>; params: Promise<{ account: string; batchId: string }>;
@@ -74,7 +74,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
api.getBatchItems(batchId), api.getBatchItems(batchId),
]); ]);
if (!batch) return <div>Einzug nicht gefunden</div>; if (!batch) return <AccountNotFound />;
const status = String(batch.status); const status = String(batch.status);
@@ -95,9 +95,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
{/* Summary Card */} {/* Summary Card */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle> <CardTitle>{String(batch.description ?? 'SEPA-Einzug')}</CardTitle>
{String(batch.description ?? 'SEPA-Einzug')}
</CardTitle>
<Badge variant={STATUS_VARIANT[status] ?? 'secondary'}> <Badge variant={STATUS_VARIANT[status] ?? 'secondary'}>
{STATUS_LABEL[status] ?? status} {STATUS_LABEL[status] ?? status}
</Badge> </Badge>
@@ -105,7 +103,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
<CardContent> <CardContent>
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-muted-foreground text-sm font-medium">
Typ Typ
</dt> </dt>
<dd className="mt-1 text-sm font-semibold"> <dd className="mt-1 text-sm font-semibold">
@@ -115,7 +113,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-muted-foreground text-sm font-medium">
Betrag Betrag
</dt> </dt>
<dd className="mt-1 text-sm font-semibold"> <dd className="mt-1 text-sm font-semibold">
@@ -125,7 +123,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-muted-foreground text-sm font-medium">
Anzahl Anzahl
</dt> </dt>
<dd className="mt-1 text-sm font-semibold"> <dd className="mt-1 text-sm font-semibold">
@@ -133,15 +131,11 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-muted-foreground text-sm font-medium">
Ausführungsdatum Ausführungsdatum
</dt> </dt>
<dd className="mt-1 text-sm font-semibold"> <dd className="mt-1 text-sm font-semibold">
{batch.execution_date {formatDate(batch.execution_date)}
? new Date(
String(batch.execution_date),
).toLocaleDateString('de-DE')
: '—'}
</dd> </dd>
</div> </div>
</dl> </dl>
@@ -162,14 +156,14 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{items.length === 0 ? ( {items.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground"> <p className="text-muted-foreground py-8 text-center text-sm">
Keine Positionen vorhanden. Keine Positionen vorhanden.
</p> </p>
) : ( ) : (
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Name</th> <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-left font-medium">IBAN</th>
<th className="p-3 text-right font-medium">Betrag</th> <th className="p-3 text-right font-medium">Betrag</th>
@@ -182,7 +176,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
return ( return (
<tr <tr
key={String(item.id)} key={String(item.id)}
className="border-b hover:bg-muted/30" className="hover:bg-muted/30 border-b"
> >
<td className="p-3 font-medium"> <td className="p-3 font-medium">
{String(item.debtor_name ?? '—')} {String(item.debtor_name ?? '—')}

View File

@@ -1,8 +1,8 @@
import { CreateSepaBatchForm } from '@kit/finance/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateSepaBatchForm } from '@kit/finance/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -21,7 +21,11 @@ export default async function NewSepaBatchPage({ params }: Props) {
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell account={account} title="Neuer SEPA-Einzug" description="SEPA-Lastschrifteinzug erstellen"> <CmsPageShell
account={account}
title="Neuer SEPA-Einzug"
description="SEPA-Lastschrifteinzug erstellen"
>
<CreateSepaBatchForm accountId={acct.id} account={account} /> <CreateSepaBatchForm accountId={acct.id} account={account} />
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -2,20 +2,17 @@ import Link from 'next/link';
import { Landmark, Plus } from 'lucide-react'; import { Landmark, Plus } from 'lucide-react';
import { createFinanceApi } from '@kit/finance/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createFinanceApi } from '@kit/finance/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found'; import { BATCH_STATUS_VARIANT, BATCH_STATUS_LABEL } from '~/lib/status-badges';
import {
BATCH_STATUS_VARIANT,
BATCH_STATUS_LABEL,
} from '~/lib/status-badges';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -79,7 +76,7 @@ export default async function SepaPage({ params }: PageProps) {
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Status</th> <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">Typ</th>
<th className="p-3 text-left font-medium"> <th className="p-3 text-left font-medium">
@@ -96,12 +93,13 @@ export default async function SepaPage({ params }: PageProps) {
{batches.map((batch: Record<string, unknown>) => ( {batches.map((batch: Record<string, unknown>) => (
<tr <tr
key={String(batch.id)} key={String(batch.id)}
className="border-b hover:bg-muted/30" className="hover:bg-muted/30 border-b"
> >
<td className="p-3"> <td className="p-3">
<Badge <Badge
variant={ variant={
BATCH_STATUS_VARIANT[String(batch.status)] ?? 'secondary' BATCH_STATUS_VARIANT[String(batch.status)] ??
'secondary'
} }
> >
{BATCH_STATUS_LABEL[String(batch.status)] ?? {BATCH_STATUS_LABEL[String(batch.status)] ??
@@ -130,11 +128,7 @@ export default async function SepaPage({ params }: PageProps) {
{String(batch.item_count ?? 0)} {String(batch.item_count ?? 0)}
</td> </td>
<td className="p-3"> <td className="p-3">
{batch.execution_date {formatDate(batch.execution_date)}
? new Date(
String(batch.execution_date),
).toLocaleDateString('de-DE')
: '—'}
</td> </td>
</tr> </tr>
))} ))}

View File

@@ -1,9 +1,12 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createFischereiApi } from '@kit/fischerei/api'; import { createFischereiApi } from '@kit/fischerei/api';
import { FischereiTabNavigation, CatchBooksDataTable } from '@kit/fischerei/components'; import {
FischereiTabNavigation,
CatchBooksDataTable,
} from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;

View File

@@ -1,16 +1,22 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createFischereiApi } from '@kit/fischerei/api'; import { createFischereiApi } from '@kit/fischerei/api';
import { FischereiTabNavigation, CompetitionsDataTable } from '@kit/fischerei/components'; import {
FischereiTabNavigation,
CompetitionsDataTable,
} from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>; searchParams: Promise<Record<string, string | string[] | undefined>>;
} }
export default async function CompetitionsPage({ params, searchParams }: Props) { export default async function CompetitionsPage({
params,
searchParams,
}: Props) {
const { account } = await params; const { account } = await params;
const search = await searchParams; const search = await searchParams;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();

View File

@@ -1,13 +1,13 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createFischereiApi } from '@kit/fischerei/api'; import { createFischereiApi } from '@kit/fischerei/api';
import { FischereiTabNavigation } from '@kit/fischerei/components'; import { FischereiTabNavigation } from '@kit/fischerei/components';
import { LEASE_PAYMENT_LABELS } from '@kit/fischerei/lib/fischerei-constants';
import { CmsPageShell } from '~/components/cms-page-shell'; import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { LEASE_PAYMENT_LABELS } from '@kit/fischerei/lib/fischerei-constants';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -54,7 +54,7 @@ export default async function LeasesPage({ params, searchParams }: Props) {
<h3 className="text-lg font-semibold"> <h3 className="text-lg font-semibold">
Keine Pachten vorhanden Keine Pachten vorhanden
</h3> </h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground"> <p className="text-muted-foreground mt-1 max-w-sm text-sm">
Erstellen Sie Ihren ersten Pachtvertrag. Erstellen Sie Ihren ersten Pachtvertrag.
</p> </p>
</div> </div>
@@ -62,22 +62,32 @@ export default async function LeasesPage({ params, searchParams }: Props) {
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Verpächter</th> <th className="p-3 text-left font-medium">Verpächter</th>
<th className="p-3 text-left font-medium">Gewässer</th> <th className="p-3 text-left font-medium">Gewässer</th>
<th className="p-3 text-left font-medium">Beginn</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">Ende</th>
<th className="p-3 text-right font-medium">Jahresbetrag ()</th> <th className="p-3 text-right font-medium">
Jahresbetrag ()
</th>
<th className="p-3 text-left font-medium">Zahlungsart</th> <th className="p-3 text-left font-medium">Zahlungsart</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{result.data.map((lease: Record<string, unknown>) => { {result.data.map((lease: Record<string, unknown>) => {
const waters = lease.waters as Record<string, unknown> | null; const waters = lease.waters as Record<
const paymentMethod = String(lease.payment_method ?? 'ueberweisung'); string,
unknown
> | null;
const paymentMethod = String(
lease.payment_method ?? 'ueberweisung',
);
return ( return (
<tr key={String(lease.id)} className="border-b hover:bg-muted/30"> <tr
key={String(lease.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium"> <td className="p-3 font-medium">
{String(lease.lessor_name)} {String(lease.lessor_name)}
</td> </td>
@@ -85,13 +95,11 @@ export default async function LeasesPage({ params, searchParams }: Props) {
{waters ? String(waters.name) : '—'} {waters ? String(waters.name) : '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
{lease.start_date {formatDate(lease.start_date)}
? new Date(String(lease.start_date)).toLocaleDateString('de-DE')
: '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
{lease.end_date {lease.end_date
? new Date(String(lease.end_date)).toLocaleDateString('de-DE') ? formatDate(lease.end_date)
: 'unbefristet'} : 'unbefristet'}
</td> </td>
<td className="p-3 text-right"> <td className="p-3 text-right">
@@ -101,7 +109,8 @@ export default async function LeasesPage({ params, searchParams }: Props) {
</td> </td>
<td className="p-3"> <td className="p-3">
<Badge variant="outline"> <Badge variant="outline">
{LEASE_PAYMENT_LABELS[paymentMethod] ?? paymentMethod} {LEASE_PAYMENT_LABELS[paymentMethod] ??
paymentMethod}
</Badge> </Badge>
</td> </td>
</tr> </tr>

View File

@@ -1,9 +1,12 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createFischereiApi } from '@kit/fischerei/api'; import { createFischereiApi } from '@kit/fischerei/api';
import { FischereiTabNavigation, FischereiDashboard } from '@kit/fischerei/components'; import {
FischereiTabNavigation,
FischereiDashboard,
} from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;

View File

@@ -1,10 +1,10 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createFischereiApi } from '@kit/fischerei/api'; import { createFischereiApi } from '@kit/fischerei/api';
import { FischereiTabNavigation } from '@kit/fischerei/components'; import { FischereiTabNavigation } from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CmsPageShell } from '~/components/cms-page-shell';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -45,7 +45,7 @@ export default async function PermitsPage({ params }: Props) {
<h3 className="text-lg font-semibold"> <h3 className="text-lg font-semibold">
Keine Erlaubnisscheine vorhanden Keine Erlaubnisscheine vorhanden
</h3> </h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground"> <p className="text-muted-foreground mt-1 max-w-sm text-sm">
Erstellen Sie Ihren ersten Erlaubnisschein. Erstellen Sie Ihren ersten Erlaubnisschein.
</p> </p>
</div> </div>
@@ -53,22 +53,36 @@ export default async function PermitsPage({ params }: Props) {
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Bezeichnung</th> <th className="p-3 text-left font-medium">Bezeichnung</th>
<th className="p-3 text-left font-medium">Kurzcode</th> <th className="p-3 text-left font-medium">Kurzcode</th>
<th className="p-3 text-left font-medium">Hauptgewässer</th> <th className="p-3 text-left font-medium">
<th className="p-3 text-right font-medium">Gesamtmenge</th> Hauptgewässer
<th className="p-3 text-center font-medium">Zum Verkauf</th> </th>
<th className="p-3 text-right font-medium">
Gesamtmenge
</th>
<th className="p-3 text-center font-medium">
Zum Verkauf
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{permits.map((permit: Record<string, unknown>) => { {permits.map((permit: Record<string, unknown>) => {
const waters = permit.waters as Record<string, unknown> | null; const waters = permit.waters as Record<
string,
unknown
> | null;
return ( return (
<tr key={String(permit.id)} className="border-b hover:bg-muted/30"> <tr
<td className="p-3 font-medium">{String(permit.name)}</td> key={String(permit.id)}
<td className="p-3 text-muted-foreground"> className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">
{String(permit.name)}
</td>
<td className="text-muted-foreground p-3">
{String(permit.short_code ?? '—')} {String(permit.short_code ?? '—')}
</td> </td>
<td className="p-3"> <td className="p-3">

View File

@@ -1,8 +1,11 @@
import {
FischereiTabNavigation,
CreateSpeciesForm,
} from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { FischereiTabNavigation, CreateSpeciesForm } from '@kit/fischerei/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;

View File

@@ -1,9 +1,12 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createFischereiApi } from '@kit/fischerei/api'; import { createFischereiApi } from '@kit/fischerei/api';
import { FischereiTabNavigation, SpeciesDataTable } from '@kit/fischerei/components'; import {
FischereiTabNavigation,
SpeciesDataTable,
} from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;

View File

@@ -1,9 +1,9 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { FischereiTabNavigation } from '@kit/fischerei/components'; import { FischereiTabNavigation } from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CmsPageShell } from '~/components/cms-page-shell';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -37,9 +37,12 @@ export default async function StatisticsPage({ params }: Props) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center"> <div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">Noch keine Daten vorhanden</h3> <h3 className="text-lg font-semibold">
<p className="mt-1 max-w-sm text-sm text-muted-foreground"> Noch keine Daten vorhanden
Sobald Fangbücher eingereicht und geprüft werden, erscheinen hier Statistiken und Auswertungen. </h3>
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
Sobald Fangbücher eingereicht und geprüft werden, erscheinen
hier Statistiken und Auswertungen.
</p> </p>
</div> </div>
</CardContent> </CardContent>

View File

@@ -1,9 +1,12 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createFischereiApi } from '@kit/fischerei/api'; import { createFischereiApi } from '@kit/fischerei/api';
import { FischereiTabNavigation, CreateStockingForm } from '@kit/fischerei/components'; import {
FischereiTabNavigation,
CreateStockingForm,
} from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;

View File

@@ -1,9 +1,12 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createFischereiApi } from '@kit/fischerei/api'; import { createFischereiApi } from '@kit/fischerei/api';
import { FischereiTabNavigation, StockingDataTable } from '@kit/fischerei/components'; import {
FischereiTabNavigation,
StockingDataTable,
} from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;

View File

@@ -1,8 +1,11 @@
import {
FischereiTabNavigation,
CreateWaterForm,
} from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { FischereiTabNavigation, CreateWaterForm } from '@kit/fischerei/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;

View File

@@ -1,9 +1,12 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createFischereiApi } from '@kit/fischerei/api'; import { createFischereiApi } from '@kit/fischerei/api';
import { FischereiTabNavigation, WatersDataTable } from '@kit/fischerei/components'; import {
FischereiTabNavigation,
WatersDataTable,
} from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;

View File

@@ -70,7 +70,8 @@ function injectAccountFeatureRoutes(
account: string, account: string,
features: Record<string, boolean>, features: Record<string, boolean>,
): z.output<typeof NavigationConfigSchema> { ): z.output<typeof NavigationConfigSchema> {
if (!features.fischerei && !features.meetings && !features.verband) return config; if (!features.fischerei && !features.meetings && !features.verband)
return config;
const featureEntries: Array<{ const featureEntries: Array<{
label: string; label: string;

View File

@@ -1,9 +1,12 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMeetingsApi } from '@kit/sitzungsprotokolle/api'; import { createMeetingsApi } from '@kit/sitzungsprotokolle/api';
import { MeetingsTabNavigation, MeetingsDashboard } from '@kit/sitzungsprotokolle/components'; import {
MeetingsTabNavigation,
MeetingsDashboard,
} from '@kit/sitzungsprotokolle/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;

View File

@@ -2,18 +2,20 @@ import Link from 'next/link';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { formatDateFull } from '@kit/shared/dates';
import { createMeetingsApi } from '@kit/sitzungsprotokolle/api';
import {
MeetingsTabNavigation,
ProtocolItemsList,
} from '@kit/sitzungsprotokolle/components';
import { MEETING_TYPE_LABELS } from '@kit/sitzungsprotokolle/lib/meetings-constants';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMeetingsApi } from '@kit/sitzungsprotokolle/api';
import { MeetingsTabNavigation, ProtocolItemsList } from '@kit/sitzungsprotokolle/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import { MEETING_TYPE_LABELS } from '@kit/sitzungsprotokolle/lib/meetings-constants';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps { interface PageProps {
params: Promise<{ account: string; protocolId: string }>; params: Promise<{ account: string; protocolId: string }>;
@@ -39,9 +41,12 @@ export default async function ProtocolDetailPage({ params }: PageProps) {
} catch { } catch {
return ( return (
<CmsPageShell account={account} title="Sitzungsprotokolle"> <CmsPageShell account={account} title="Sitzungsprotokolle">
<div className="text-center py-12"> <div className="py-12 text-center">
<h2 className="text-lg font-semibold">Protokoll nicht gefunden</h2> <h2 className="text-lg font-semibold">Protokoll nicht gefunden</h2>
<Link href={`/home/${account}/meetings/protocols`} className="mt-4 inline-block"> <Link
href={`/home/${account}/meetings/protocols`}
className="mt-4 inline-block"
>
<Button variant="outline">Zurück zur Übersicht</Button> <Button variant="outline">Zurück zur Übersicht</Button>
</Link> </Link>
</div> </div>
@@ -72,18 +77,12 @@ export default async function ProtocolDetailPage({ params }: PageProps) {
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<CardTitle className="text-xl">{protocol.title}</CardTitle> <CardTitle className="text-xl">{protocol.title}</CardTitle>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground"> <div className="text-muted-foreground mt-2 flex flex-wrap items-center gap-2 text-sm">
<span> <span>{formatDateFull(protocol.meeting_date)}</span>
{new Date(protocol.meeting_date).toLocaleDateString('de-DE', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
<span>·</span> <span>·</span>
<Badge variant="secondary"> <Badge variant="secondary">
{MEETING_TYPE_LABELS[protocol.meeting_type] ?? protocol.meeting_type} {MEETING_TYPE_LABELS[protocol.meeting_type] ??
protocol.meeting_type}
</Badge> </Badge>
{protocol.is_published ? ( {protocol.is_published ? (
<Badge variant="default">Veröffentlicht</Badge> <Badge variant="default">Veröffentlicht</Badge>
@@ -97,20 +96,28 @@ export default async function ProtocolDetailPage({ params }: PageProps) {
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{protocol.location && ( {protocol.location && (
<div> <div>
<p className="text-sm font-medium text-muted-foreground">Ort</p> <p className="text-muted-foreground text-sm font-medium">Ort</p>
<p className="text-sm">{protocol.location}</p> <p className="text-sm">{protocol.location}</p>
</div> </div>
)} )}
{protocol.attendees && ( {protocol.attendees && (
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<p className="text-sm font-medium text-muted-foreground">Teilnehmer</p> <p className="text-muted-foreground text-sm font-medium">
<p className="text-sm whitespace-pre-line">{protocol.attendees}</p> Teilnehmer
</p>
<p className="text-sm whitespace-pre-line">
{protocol.attendees}
</p>
</div> </div>
)} )}
{protocol.remarks && ( {protocol.remarks && (
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<p className="text-sm font-medium text-muted-foreground">Anmerkungen</p> <p className="text-muted-foreground text-sm font-medium">
<p className="text-sm whitespace-pre-line">{protocol.remarks}</p> Anmerkungen
</p>
<p className="text-sm whitespace-pre-line">
{protocol.remarks}
</p>
</div> </div>
)} )}
</CardContent> </CardContent>

View File

@@ -1,8 +1,11 @@
import {
MeetingsTabNavigation,
CreateProtocolForm,
} from '@kit/sitzungsprotokolle/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { MeetingsTabNavigation, CreateProtocolForm } from '@kit/sitzungsprotokolle/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;

View File

@@ -1,16 +1,22 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMeetingsApi } from '@kit/sitzungsprotokolle/api'; import { createMeetingsApi } from '@kit/sitzungsprotokolle/api';
import { MeetingsTabNavigation, ProtocolsDataTable } from '@kit/sitzungsprotokolle/components'; import {
MeetingsTabNavigation,
ProtocolsDataTable,
} from '@kit/sitzungsprotokolle/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>; searchParams: Promise<Record<string, string | string[] | undefined>>;
} }
export default async function ProtocolsPage({ params, searchParams }: PageProps) { export default async function ProtocolsPage({
params,
searchParams,
}: PageProps) {
const { account } = await params; const { account } = await params;
const sp = await searchParams; const sp = await searchParams;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();

View File

@@ -1,9 +1,12 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMeetingsApi } from '@kit/sitzungsprotokolle/api'; import { createMeetingsApi } from '@kit/sitzungsprotokolle/api';
import { MeetingsTabNavigation, OpenTasksView } from '@kit/sitzungsprotokolle/components'; import {
MeetingsTabNavigation,
OpenTasksView,
} from '@kit/sitzungsprotokolle/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -36,7 +39,8 @@ export default async function TasksPage({ params, searchParams }: PageProps) {
<div> <div>
<h1 className="text-2xl font-bold">Offene Aufgaben</h1> <h1 className="text-2xl font-bold">Offene Aufgaben</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Alle offenen und in Bearbeitung befindlichen Tagesordnungspunkte über alle Protokolle. Alle offenen und in Bearbeitung befindlichen Tagesordnungspunkte
über alle Protokolle.
</p> </p>
</div> </div>
<OpenTasksView <OpenTasksView

View File

@@ -1,8 +1,9 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMemberManagementApi } from '@kit/member-management/api'; import { createMemberManagementApi } from '@kit/member-management/api';
import { EditMemberForm } from '@kit/member-management/components'; import { EditMemberForm } from '@kit/member-management/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props { interface Props {
params: Promise<{ account: string; memberId: string }>; params: Promise<{ account: string; memberId: string }>;
@@ -11,7 +12,11 @@ interface Props {
export default async function EditMemberPage({ params }: Props) { export default async function EditMemberPage({ params }: Props) {
const { account, memberId } = await params; const { account, memberId } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); const api = createMemberManagementApi(client);
@@ -19,7 +24,10 @@ export default async function EditMemberPage({ params }: Props) {
if (!member) return <div>Mitglied nicht gefunden</div>; if (!member) return <div>Mitglied nicht gefunden</div>;
return ( return (
<CmsPageShell account={account} title={`${String(member.first_name)} ${String(member.last_name)} bearbeiten`}> <CmsPageShell
account={account}
title={`${String(member.first_name)} ${String(member.last_name)} bearbeiten`}
>
<EditMemberForm member={member} account={account} accountId={acct.id} /> <EditMemberForm member={member} account={account} accountId={acct.id} />
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -1,8 +1,9 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMemberManagementApi } from '@kit/member-management/api'; import { createMemberManagementApi } from '@kit/member-management/api';
import { MemberDetailView } from '@kit/member-management/components'; import { MemberDetailView } from '@kit/member-management/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props { interface Props {
params: Promise<{ account: string; memberId: string }>; params: Promise<{ account: string; memberId: string }>;
@@ -11,7 +12,11 @@ interface Props {
export default async function MemberDetailPage({ params }: Props) { export default async function MemberDetailPage({ params }: Props) {
const { account, memberId } = await params; const { account, memberId } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); const api = createMemberManagementApi(client);
@@ -19,7 +24,10 @@ export default async function MemberDetailPage({ params }: Props) {
if (!member) return <div>Mitglied nicht gefunden</div>; if (!member) return <div>Mitglied nicht gefunden</div>;
return ( return (
<CmsPageShell account={account} title={`${String(member.first_name)} ${String(member.last_name)}`}> <CmsPageShell
account={account}
title={`${String(member.first_name)} ${String(member.last_name)}`}
>
<MemberDetailView member={member} account={account} accountId={acct.id} /> <MemberDetailView member={member} account={account} accountId={acct.id} />
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -1,8 +1,9 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMemberManagementApi } from '@kit/member-management/api'; import { createMemberManagementApi } from '@kit/member-management/api';
import { ApplicationWorkflow } from '@kit/member-management/components'; import { ApplicationWorkflow } from '@kit/member-management/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -11,15 +12,27 @@ interface Props {
export default async function ApplicationsPage({ params }: Props) { export default async function ApplicationsPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); const api = createMemberManagementApi(client);
const applications = await api.listApplications(acct.id); const applications = await api.listApplications(acct.id);
return ( return (
<CmsPageShell account={account} title="Aufnahmeanträge" description="Mitgliedsanträge bearbeiten"> <CmsPageShell
<ApplicationWorkflow applications={applications} accountId={acct.id} account={account} /> account={account}
title="Aufnahmeanträge"
description="Mitgliedsanträge bearbeiten"
>
<ApplicationWorkflow
applications={applications}
accountId={acct.id}
account={account}
/>
</CmsPageShell> </CmsPageShell>
); );
} }

View File

@@ -1,9 +1,11 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMemberManagementApi } from '@kit/member-management/api';
import { CreditCard } from 'lucide-react'; import { CreditCard } from 'lucide-react';
import { createMemberManagementApi } from '@kit/member-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
/** All active members are fetched for the card overview. */ /** All active members are fetched for the card overview. */
const CARDS_PAGE_SIZE = 100; const CARDS_PAGE_SIZE = 100;
@@ -15,15 +17,26 @@ interface Props {
export default async function MemberCardsPage({ params }: Props) { export default async function MemberCardsPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); const api = createMemberManagementApi(client);
const result = await api.listMembers(acct.id, { status: 'active', pageSize: CARDS_PAGE_SIZE }); const result = await api.listMembers(acct.id, {
status: 'active',
pageSize: CARDS_PAGE_SIZE,
});
const members = result.data; const members = result.data;
return ( return (
<CmsPageShell account={account} title="Mitgliedsausweise" description="Ausweise erstellen und verwalten"> <CmsPageShell
account={account}
title="Mitgliedsausweise"
description="Ausweise erstellen und verwalten"
>
{members.length === 0 ? ( {members.length === 0 ? (
<EmptyState <EmptyState
icon={<CreditCard className="h-8 w-8" />} icon={<CreditCard className="h-8 w-8" />}

View File

@@ -1,12 +1,14 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { toast } from '@kit/ui/sonner';
import { Plus } from 'lucide-react';
import { useAction } from 'next-safe-action/hooks';
import { createDepartment } from '@kit/member-management/actions/member-actions';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -16,15 +18,17 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@kit/ui/dialog'; } from '@kit/ui/dialog';
import { Plus } from 'lucide-react'; import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { createDepartment } from '@kit/member-management/actions/member-actions'; import { toast } from '@kit/ui/sonner';
interface CreateDepartmentDialogProps { interface CreateDepartmentDialogProps {
accountId: string; accountId: string;
} }
export function CreateDepartmentDialog({ accountId }: CreateDepartmentDialogProps) { export function CreateDepartmentDialog({
accountId,
}: CreateDepartmentDialogProps) {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [name, setName] = useState(''); const [name, setName] = useState('');
@@ -49,7 +53,11 @@ export function CreateDepartmentDialog({ accountId }: CreateDepartmentDialogProp
(e: React.FormEvent) => { (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!name.trim()) return; if (!name.trim()) return;
execute({ accountId, name: name.trim(), description: description.trim() || undefined }); execute({
accountId,
name: name.trim(),
description: description.trim() || undefined,
});
}, },
[execute, accountId, name, description], [execute, accountId, name, description],
); );

View File

@@ -1,9 +1,11 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Users } from 'lucide-react';
import { createMemberManagementApi } from '@kit/member-management/api'; import { createMemberManagementApi } from '@kit/member-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { Users } from 'lucide-react';
import { AccountNotFound } from '~/components/account-not-found';
import { CreateDepartmentDialog } from './create-department-dialog'; import { CreateDepartmentDialog } from './create-department-dialog';
@@ -14,14 +16,22 @@ interface Props {
export default async function DepartmentsPage({ params }: Props) { export default async function DepartmentsPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); const api = createMemberManagementApi(client);
const departments = await api.listDepartments(acct.id); const departments = await api.listDepartments(acct.id);
return ( return (
<CmsPageShell account={account} title="Abteilungen" description="Sparten und Abteilungen verwalten"> <CmsPageShell
account={account}
title="Abteilungen"
description="Sparten und Abteilungen verwalten"
>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<CreateDepartmentDialog accountId={acct.id} /> <CreateDepartmentDialog accountId={acct.id} />
@@ -37,16 +47,21 @@ export default async function DepartmentsPage({ params }: Props) {
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Name</th> <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">Beschreibung</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{departments.map((dept: Record<string, unknown>) => ( {departments.map((dept: Record<string, unknown>) => (
<tr key={String(dept.id)} className="border-b hover:bg-muted/30"> <tr
key={String(dept.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">{String(dept.name)}</td> <td className="p-3 font-medium">{String(dept.name)}</td>
<td className="p-3 text-muted-foreground">{String(dept.description ?? '—')}</td> <td className="text-muted-foreground p-3">
{String(dept.description ?? '—')}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -1,8 +1,9 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMemberManagementApi } from '@kit/member-management/api'; import { createMemberManagementApi } from '@kit/member-management/api';
import { DuesCategoryManager } from '@kit/member-management/components'; import { DuesCategoryManager } from '@kit/member-management/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -11,14 +12,22 @@ interface Props {
export default async function DuesPage({ params }: Props) { export default async function DuesPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); const api = createMemberManagementApi(client);
const categories = await api.listDuesCategories(acct.id); const categories = await api.listDuesCategories(acct.id);
return ( return (
<CmsPageShell account={account} title="Beitragskategorien" description="Mitgliedsbeiträge verwalten"> <CmsPageShell
account={account}
title="Beitragskategorien"
description="Mitgliedsbeiträge verwalten"
>
<DuesCategoryManager categories={categories} accountId={acct.id} /> <DuesCategoryManager categories={categories} accountId={acct.id} />
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -1,7 +1,8 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { MemberImportWizard } from '@kit/member-management/components'; import { MemberImportWizard } from '@kit/member-management/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -10,11 +11,19 @@ interface Props {
export default async function MemberImportPage({ params }: Props) { export default async function MemberImportPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell account={account} title="Mitglieder importieren" description="CSV-Datei importieren"> <CmsPageShell
account={account}
title="Mitglieder importieren"
description="CSV-Datei importieren"
>
<MemberImportWizard accountId={acct.id} account={account} /> <MemberImportWizard accountId={acct.id} account={account} />
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -1,28 +1,43 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMemberManagementApi } from '@kit/member-management/api'; import { createMemberManagementApi } from '@kit/member-management/api';
import { CreateMemberForm } from '@kit/member-management/components'; import { CreateMemberForm } from '@kit/member-management/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { params: Promise<{ account: string }> } import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string }>;
}
export default async function NewMemberPage({ params }: Props) { export default async function NewMemberPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); const api = createMemberManagementApi(client);
const duesCategories = await api.listDuesCategories(acct.id); const duesCategories = await api.listDuesCategories(acct.id);
return ( return (
<CmsPageShell account={account} title="Neues Mitglied" description="Mitglied manuell anlegen"> <CmsPageShell
<CreateMemberForm account={account}
accountId={acct.id} title="Neues Mitglied"
account={account} description="Mitglied manuell anlegen"
duesCategories={(duesCategories ?? []).map((c: Record<string, unknown>) => ({ >
id: String(c.id), name: String(c.name), amount: Number(c.amount ?? 0) <CreateMemberForm
}))} accountId={acct.id}
account={account}
duesCategories={(duesCategories ?? []).map(
(c: Record<string, unknown>) => ({
id: String(c.id),
name: String(c.name),
amount: Number(c.amount ?? 0),
}),
)}
/> />
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -1,8 +1,9 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMemberManagementApi } from '@kit/member-management/api'; import { createMemberManagementApi } from '@kit/member-management/api';
import { MembersDataTable } from '@kit/member-management/components'; import { MembersDataTable } from '@kit/member-management/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
@@ -15,7 +16,11 @@ export default async function MembersPage({ params, searchParams }: Props) {
const { account } = await params; const { account } = await params;
const search = await searchParams; const search = await searchParams;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); const api = createMemberManagementApi(client);
@@ -29,16 +34,23 @@ export default async function MembersPage({ params, searchParams }: Props) {
const duesCategories = await api.listDuesCategories(acct.id); const duesCategories = await api.listDuesCategories(acct.id);
return ( return (
<CmsPageShell account={account} title="Mitglieder" description={`${result.total} Mitglieder`}> <CmsPageShell
account={account}
title="Mitglieder"
description={`${result.total} Mitglieder`}
>
<MembersDataTable <MembersDataTable
data={result.data} data={result.data}
total={result.total} total={result.total}
page={page} page={page}
pageSize={PAGE_SIZE} pageSize={PAGE_SIZE}
account={account} account={account}
duesCategories={(duesCategories ?? []).map((c: Record<string, unknown>) => ({ duesCategories={(duesCategories ?? []).map(
id: String(c.id), name: String(c.name), (c: Record<string, unknown>) => ({
}))} id: String(c.id),
name: String(c.name),
}),
)}
/> />
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -1,14 +1,20 @@
import { Users, UserCheck, UserMinus, Clock, BarChart3, TrendingUp } from 'lucide-react'; import {
Users,
UserCheck,
UserMinus,
Clock,
BarChart3,
TrendingUp,
} from 'lucide-react';
import { createMemberManagementApi } from '@kit/member-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createMemberManagementApi } from '@kit/member-management/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { StatsCard } from '~/components/stats-card'; import { StatsCard } from '~/components/stats-card';
import { StatsBarChart, StatsPieChart } from '~/components/stats-charts'; import { StatsBarChart, StatsPieChart } from '~/components/stats-charts';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -40,10 +46,26 @@ export default async function MemberStatisticsPage({ params }: PageProps) {
<CmsPageShell account={account} title="Mitglieder-Statistiken"> <CmsPageShell account={account} title="Mitglieder-Statistiken">
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <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
<StatsCard title="Aktiv" value={stats.active ?? 0} icon={<UserCheck className="h-5 w-5" />} /> title="Gesamt"
<StatsCard title="Inaktiv" value={stats.inactive ?? 0} icon={<UserMinus className="h-5 w-5" />} /> value={stats.total ?? 0}
<StatsCard title="Ausstehend" value={stats.pending ?? 0} icon={<Clock className="h-5 w-5" />} /> 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> </div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">

View File

@@ -1,6 +1,8 @@
import 'server-only'; import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import { loadTeamWorkspace } from '~/home/[account]/_lib/server/team-account-workspace.loader'; import { loadTeamWorkspace } from '~/home/[account]/_lib/server/team-account-workspace.loader';
import { Database } from '~/lib/database.types'; import { Database } from '~/lib/database.types';
@@ -49,7 +51,11 @@ async function loadAccountMembers(
}); });
if (error) { if (error) {
console.error(error); const logger = await getLogger();
logger.error(
{ error, context: 'load-account-members' },
'Failed to load account members',
);
throw error; throw error;
} }
@@ -70,7 +76,11 @@ async function loadInvitations(
}); });
if (error) { if (error) {
console.error(error); const logger = await getLogger();
logger.error(
{ error, context: 'load-invitations' },
'Failed to load account invitations',
);
throw error; throw error;
} }

View File

@@ -1,12 +1,14 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import Link from 'next/link';
import { Pencil, Trash2, Lock, Unlock } from 'lucide-react';
import { createModuleBuilderApi } from '@kit/module-builder/api'; import { createModuleBuilderApi } from '@kit/module-builder/api';
import { ModuleForm } from '@kit/module-builder/components'; import { ModuleForm } from '@kit/module-builder/components';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Pencil, Trash2, Lock, Unlock } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import Link from 'next/link';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
@@ -14,7 +16,9 @@ interface RecordDetailPageProps {
params: Promise<{ account: string; moduleId: string; recordId: string }>; params: Promise<{ account: string; moduleId: string; recordId: string }>;
} }
export default async function RecordDetailPage({ params }: RecordDetailPageProps) { export default async function RecordDetailPage({
params,
}: RecordDetailPageProps) {
const { account, moduleId, recordId } = await params; const { account, moduleId, recordId } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const api = createModuleBuilderApi(client); const api = createModuleBuilderApi(client);
@@ -26,29 +30,49 @@ export default async function RecordDetailPage({ params }: RecordDetailPageProps
if (!moduleWithFields || !record) return <div>Nicht gefunden</div>; if (!moduleWithFields || !record) return <div>Nicht gefunden</div>;
const fields = (moduleWithFields as unknown as { const fields = (
fields: Array<{ moduleWithFields as unknown as {
name: string; display_name: string; field_type: string; fields: Array<{
is_required: boolean; placeholder: string | null; name: string;
help_text: string | null; is_readonly: boolean; display_name: string;
select_options: Array<{ label: string; value: string }> | null; field_type: string;
section: string; sort_order: number; show_in_form: boolean; width: string; is_required: boolean;
}>; placeholder: string | null;
}).fields; 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 data = (record.data ?? {}) as Record<string, unknown>;
const isLocked = record.status === 'locked'; const isLocked = record.status === 'locked';
return ( return (
<CmsPageShell account={account} title={`${String(moduleWithFields.display_name)} — Datensatz`}> <CmsPageShell
account={account}
title={`${String(moduleWithFields.display_name)} — Datensatz`}
>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant={isLocked ? 'destructive' : record.status === 'active' ? 'default' : 'secondary'}> <Badge
variant={
isLocked
? 'destructive'
: record.status === 'active'
? 'default'
: 'secondary'
}
>
{String(record.status)} {String(record.status)}
</Badge> </Badge>
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground text-sm">
Erstellt: {new Date(record.created_at).toLocaleDateString('de-DE')} Erstellt: {formatDate(record.created_at)}
</span> </span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Settings2, List, Shield } from 'lucide-react';
import { createModuleBuilderApi } from '@kit/module-builder/api'; import { createModuleBuilderApi } from '@kit/module-builder/api';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label'; import { Label } from '@kit/ui/label';
import { Settings2, List, Shield } from 'lucide-react';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
@@ -14,7 +14,9 @@ interface ModuleSettingsPageProps {
params: Promise<{ account: string; moduleId: string }>; params: Promise<{ account: string; moduleId: string }>;
} }
export default async function ModuleSettingsPage({ params }: ModuleSettingsPageProps) { export default async function ModuleSettingsPage({
params,
}: ModuleSettingsPageProps) {
const { account, moduleId } = await params; const { account, moduleId } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const api = createModuleBuilderApi(client); const api = createModuleBuilderApi(client);
@@ -23,10 +25,14 @@ export default async function ModuleSettingsPage({ params }: ModuleSettingsPageP
if (!moduleWithFields) return <div>Modul nicht gefunden</div>; if (!moduleWithFields) return <div>Modul nicht gefunden</div>;
const mod = moduleWithFields; const mod = moduleWithFields;
const fields = (mod as unknown as { fields: Array<Record<string, unknown>> }).fields ?? []; const fields =
(mod as unknown as { fields: Array<Record<string, unknown>> }).fields ?? [];
return ( return (
<CmsPageShell account={account} title={`${String(mod.display_name)} — Einstellungen`}> <CmsPageShell
account={account}
title={`${String(mod.display_name)} — Einstellungen`}
>
<div className="space-y-6"> <div className="space-y-6">
{/* General Settings */} {/* General Settings */}
<Card> <Card>
@@ -44,7 +50,11 @@ export default async function ModuleSettingsPage({ params }: ModuleSettingsPageP
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Systemname</Label> <Label>Systemname</Label>
<Input defaultValue={String(mod.name)} readOnly className="bg-muted" /> <Input
defaultValue={String(mod.name)}
readOnly
className="bg-muted"
/>
</div> </div>
<div className="col-span-full space-y-2"> <div className="col-span-full space-y-2">
<Label>Beschreibung</Label> <Label>Beschreibung</Label>
@@ -56,7 +66,10 @@ export default async function ModuleSettingsPage({ params }: ModuleSettingsPageP
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Seitengröße</Label> <Label>Seitengröße</Label>
<Input type="number" defaultValue={String(mod.default_page_size ?? 25)} /> <Input
type="number"
defaultValue={String(mod.default_page_size ?? 25)}
/>
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -73,7 +86,11 @@ export default async function ModuleSettingsPage({ params }: ModuleSettingsPageP
].map(({ key, label }) => ( ].map(({ key, label }) => (
<Badge <Badge
key={key} key={key}
variant={(mod as Record<string, unknown>)[key] ? 'default' : 'secondary'} variant={
(mod as Record<string, unknown>)[key]
? 'default'
: 'secondary'
}
> >
{(mod as Record<string, unknown>)[key] ? '✓' : '✗'} {label} {(mod as Record<string, unknown>)[key] ? '✓' : '✗'} {label}
</Badge> </Badge>
@@ -96,7 +113,7 @@ export default async function ModuleSettingsPage({ params }: ModuleSettingsPageP
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left">Name</th> <th className="p-3 text-left">Name</th>
<th className="p-3 text-left">Anzeigename</th> <th className="p-3 text-left">Anzeigename</th>
<th className="p-3 text-left">Typ</th> <th className="p-3 text-left">Typ</th>
@@ -108,21 +125,35 @@ export default async function ModuleSettingsPage({ params }: ModuleSettingsPageP
<tbody> <tbody>
{fields.length === 0 ? ( {fields.length === 0 ? (
<tr> <tr>
<td colSpan={6} className="p-8 text-center text-muted-foreground"> <td
colSpan={6}
className="text-muted-foreground p-8 text-center"
>
Noch keine Felder definiert Noch keine Felder definiert
</td> </td>
</tr> </tr>
) : ( ) : (
fields.map((field) => ( fields.map((field) => (
<tr key={String(field.id)} className="border-b hover:bg-muted/30"> <tr
<td className="p-3 font-mono text-xs">{String(field.name)}</td> key={String(field.id)}
className="hover:bg-muted/30 border-b"
>
<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">{String(field.display_name)}</td>
<td className="p-3"> <td className="p-3">
<Badge variant="secondary">{String(field.field_type)}</Badge> <Badge variant="secondary">
{String(field.field_type)}
</Badge>
</td> </td>
<td className="p-3">{field.is_required ? '✓' : '—'}</td> <td className="p-3">{field.is_required ? '✓' : '—'}</td>
<td className="p-3">{field.show_in_table ? '✓' : '—'}</td> <td className="p-3">
<td className="p-3">{field.show_in_form ? '✓' : '—'}</td> {field.show_in_table ? '✓' : '—'}
</td>
<td className="p-3">
{field.show_in_form ? '✓' : '—'}
</td>
</tr> </tr>
)) ))
)} )}
@@ -141,8 +172,9 @@ export default async function ModuleSettingsPage({ params }: ModuleSettingsPageP
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
Modulspezifische Berechtigungen pro Rolle können hier konfiguriert werden. Modulspezifische Berechtigungen pro Rolle können hier konfiguriert
werden.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,11 +1,12 @@
'use client'; 'use client';
import { useRouter } from 'next/navigation';
import { useTransition } from 'react'; import { useTransition } from 'react';
import { Fish, FileSignature, Building2 } from 'lucide-react'; import { useRouter } from 'next/navigation';
import { toast } from '@kit/ui/sonner';
import { Fish, FileSignature, Building2 } from 'lucide-react';
import { toast } from '@kit/ui/sonner';
import { Switch } from '@kit/ui/switch'; import { Switch } from '@kit/ui/switch';
import { toggleModuleAction } from '../_lib/server/toggle-module'; import { toggleModuleAction } from '../_lib/server/toggle-module';
@@ -55,9 +56,7 @@ export function ModuleToggles({ accountId, features }: ModuleTogglesProps) {
const result = await toggleModuleAction(accountId, moduleKey, enabled); const result = await toggleModuleAction(accountId, moduleKey, enabled);
if (result.success) { if (result.success) {
toast.success( toast.success(enabled ? 'Modul aktiviert' : 'Modul deaktiviert');
enabled ? 'Modul aktiviert' : 'Modul deaktiviert',
);
router.refresh(); router.refresh();
} else { } else {

View File

@@ -18,8 +18,7 @@ export async function toggleModuleAction(
.eq('account_id', accountId) .eq('account_id', accountId)
.maybeSingle(); .maybeSingle();
const currentFeatures = const currentFeatures = (settings?.features as Record<string, boolean>) ?? {};
(settings?.features as Record<string, boolean>) ?? {};
const newFeatures = { ...currentFeatures, [moduleKey]: enabled }; const newFeatures = { ...currentFeatures, [moduleKey]: enabled };
// Upsert // Upsert

View File

@@ -1,11 +1,10 @@
import Link from 'next/link'; import Link from 'next/link';
import { createModuleBuilderApi } from '@kit/module-builder/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createModuleBuilderApi } from '@kit/module-builder/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { ModuleToggles } from './_components/module-toggles'; import { ModuleToggles } from './_components/module-toggles';
@@ -61,17 +60,15 @@ export default async function ModulesPage({ params }: ModulesPageProps) {
<Link <Link
key={module.id as string} key={module.id as string}
href={`/home/${account}/modules/${module.id as string}`} href={`/home/${account}/modules/${module.id as string}`}
className="block rounded-lg border p-4 hover:bg-accent/50 transition-colors" className="hover:bg-accent/50 block rounded-lg border p-4 transition-colors"
> >
<h3 className="font-semibold"> <h3 className="font-semibold">{String(module.display_name)}</h3>
{String(module.display_name)}
</h3>
{module.description ? ( {module.description ? (
<p className="text-sm text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1 text-sm">
{String(module.description)} {String(module.description)}
</p> </p>
) : null} ) : null}
<div className="mt-2 text-xs text-muted-foreground"> <div className="text-muted-foreground mt-2 text-xs">
Status: {String(module.status)} Status: {String(module.status)}
</div> </div>
</Link> </Link>

View File

@@ -2,16 +2,15 @@ import Link from 'next/link';
import { ArrowLeft, Send, Users } from 'lucide-react'; import { ArrowLeft, Send, Users } from 'lucide-react';
import { createNewsletterApi } from '@kit/newsletter/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createNewsletterApi } from '@kit/newsletter/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { StatsCard } from '~/components/stats-card'; import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
import { import {
NEWSLETTER_STATUS_VARIANT, NEWSLETTER_STATUS_VARIANT,
NEWSLETTER_STATUS_LABEL, NEWSLETTER_STATUS_LABEL,
@@ -42,7 +41,7 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
api.getRecipients(campaignId), api.getRecipients(campaignId),
]); ]);
if (!newsletter) return <div>Newsletter nicht gefunden</div>; if (!newsletter) return <AccountNotFound />;
const status = String(newsletter.status); const status = String(newsletter.status);
const sentCount = recipients.filter( const sentCount = recipients.filter(
@@ -61,6 +60,7 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
<Link <Link
href={`/home/${account}/newsletter`} href={`/home/${account}/newsletter`}
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm" className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
data-test="newsletter-back-link"
> >
<ArrowLeft className="mr-1 h-4 w-4" /> <ArrowLeft className="mr-1 h-4 w-4" />
Zurück zu Newsletter Zurück zu Newsletter
@@ -99,7 +99,7 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
{/* Actions */} {/* Actions */}
{status === 'draft' && ( {status === 'draft' && (
<div className="mt-6"> <div className="mt-6">
<Button> <Button data-test="newsletter-send-btn">
<Send className="mr-2 h-4 w-4" /> <Send className="mr-2 h-4 w-4" />
Newsletter versenden Newsletter versenden
</Button> </Button>
@@ -115,7 +115,7 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{recipients.length === 0 ? ( {recipients.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground"> <p className="text-muted-foreground py-8 text-center text-sm">
Keine Empfänger hinzugefügt. Fügen Sie Empfänger aus Ihrer Keine Empfänger hinzugefügt. Fügen Sie Empfänger aus Ihrer
Mitgliederliste hinzu. Mitgliederliste hinzu.
</p> </p>
@@ -123,7 +123,7 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Name</th> <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">E-Mail</th>
<th className="p-3 text-left font-medium">Status</th> <th className="p-3 text-left font-medium">Status</th>
@@ -135,7 +135,7 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
return ( return (
<tr <tr
key={String(recipient.id)} key={String(recipient.id)}
className="border-b hover:bg-muted/30" className="hover:bg-muted/30 border-b"
> >
<td className="p-3 font-medium"> <td className="p-3 font-medium">
{String(recipient.name ?? '—')} {String(recipient.name ?? '—')}
@@ -146,10 +146,12 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
<td className="p-3"> <td className="p-3">
<Badge <Badge
variant={ variant={
NEWSLETTER_RECIPIENT_STATUS_VARIANT[rStatus] ?? 'secondary' NEWSLETTER_RECIPIENT_STATUS_VARIANT[rStatus] ??
'secondary'
} }
> >
{NEWSLETTER_RECIPIENT_STATUS_LABEL[rStatus] ?? rStatus} {NEWSLETTER_RECIPIENT_STATUS_LABEL[rStatus] ??
rStatus}
</Badge> </Badge>
</td> </td>
</tr> </tr>

View File

@@ -1,18 +1,29 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateNewsletterForm } from '@kit/newsletter/components'; import { CreateNewsletterForm } from '@kit/newsletter/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { params: Promise<{ account: string }> } import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string }>;
}
export default async function NewNewsletterPage({ params }: Props) { export default async function NewNewsletterPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell account={account} title="Neuer Newsletter" description="Newsletter-Kampagne erstellen"> <CmsPageShell
account={account}
title="Neuer Newsletter"
description="Newsletter-Kampagne erstellen"
>
<CreateNewsletterForm accountId={acct.id} account={account} /> <CreateNewsletterForm accountId={acct.id} account={account} />
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -1,18 +1,25 @@
import Link from 'next/link'; import Link from 'next/link';
import { ChevronLeft, ChevronRight, Mail, Plus, Send, Users } from 'lucide-react'; import {
ChevronLeft,
ChevronRight,
Mail,
Plus,
Send,
Users,
} from 'lucide-react';
import { createNewsletterApi } from '@kit/newsletter/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createNewsletterApi } from '@kit/newsletter/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card'; import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
import { import {
NEWSLETTER_STATUS_VARIANT, NEWSLETTER_STATUS_VARIANT,
NEWSLETTER_STATUS_LABEL, NEWSLETTER_STATUS_LABEL,
@@ -25,7 +32,10 @@ interface PageProps {
searchParams: Promise<Record<string, string | string[] | undefined>>; searchParams: Promise<Record<string, string | string[] | undefined>>;
} }
export default async function NewsletterPage({ params, searchParams }: PageProps) { export default async function NewsletterPage({
params,
searchParams,
}: PageProps) {
const { account } = await params; const { account } = await params;
const search = await searchParams; const search = await searchParams;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
@@ -72,7 +82,7 @@ export default async function NewsletterPage({ params, searchParams }: PageProps
</div> </div>
<Link href={`/home/${account}/newsletter/new`}> <Link href={`/home/${account}/newsletter/new`}>
<Button> <Button data-test="newsletter-new-btn">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neuer Newsletter Neuer Newsletter
</Button> </Button>
@@ -116,7 +126,7 @@ export default async function NewsletterPage({ params, searchParams }: PageProps
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Betreff</th> <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-left font-medium">Status</th>
<th className="p-3 text-right font-medium">Empfänger</th> <th className="p-3 text-right font-medium">Empfänger</th>
@@ -128,7 +138,7 @@ export default async function NewsletterPage({ params, searchParams }: PageProps
{newsletters.map((nl: Record<string, unknown>) => ( {newsletters.map((nl: Record<string, unknown>) => (
<tr <tr
key={String(nl.id)} key={String(nl.id)}
className="border-b hover:bg-muted/30" className="hover:bg-muted/30 border-b"
> >
<td className="p-3 font-medium"> <td className="p-3 font-medium">
<Link <Link
@@ -141,10 +151,12 @@ export default async function NewsletterPage({ params, searchParams }: PageProps
<td className="p-3"> <td className="p-3">
<Badge <Badge
variant={ variant={
NEWSLETTER_STATUS_VARIANT[String(nl.status)] ?? 'secondary' NEWSLETTER_STATUS_VARIANT[String(nl.status)] ??
'secondary'
} }
> >
{NEWSLETTER_STATUS_LABEL[String(nl.status)] ?? String(nl.status)} {NEWSLETTER_STATUS_LABEL[String(nl.status)] ??
String(nl.status)}
</Badge> </Badge>
</td> </td>
<td className="p-3 text-right"> <td className="p-3 text-right">
@@ -152,16 +164,8 @@ export default async function NewsletterPage({ params, searchParams }: PageProps
? String(nl.total_recipients) ? String(nl.total_recipients)
: '—'} : '—'}
</td> </td>
<td className="p-3"> <td className="p-3">{formatDate(nl.created_at)}</td>
{nl.created_at <td className="p-3">{formatDate(nl.sent_at)}</td>
? 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> </tr>
))} ))}
</tbody> </tbody>
@@ -171,12 +175,15 @@ export default async function NewsletterPage({ params, searchParams }: PageProps
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between pt-4"> <div className="flex items-center justify-between pt-4">
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
{startIdx + 1}{Math.min(startIdx + PAGE_SIZE, totalItems)} von {totalItems} {startIdx + 1}{Math.min(startIdx + PAGE_SIZE, totalItems)}{' '}
von {totalItems}
</p> </p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{safePage > 1 ? ( {safePage > 1 ? (
<Link href={`/home/${account}/newsletter?page=${safePage - 1}`}> <Link
href={`/home/${account}/newsletter?page=${safePage - 1}`}
>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
@@ -192,7 +199,9 @@ export default async function NewsletterPage({ params, searchParams }: PageProps
</span> </span>
{safePage < totalPages ? ( {safePage < totalPages ? (
<Link href={`/home/${account}/newsletter?page=${safePage + 1}`}> <Link
href={`/home/${account}/newsletter?page=${safePage + 1}`}
>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>

View File

@@ -2,16 +2,15 @@ import Link from 'next/link';
import { FileText, Plus } from 'lucide-react'; import { FileText, Plus } from 'lucide-react';
import { createNewsletterApi } from '@kit/newsletter/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createNewsletterApi } from '@kit/newsletter/api'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -44,7 +43,7 @@ export default async function NewsletterTemplatesPage({ params }: PageProps) {
</p> </p>
</div> </div>
<Button> <Button data-test="newsletter-templates-new-btn">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neue Vorlage Neue Vorlage
</Button> </Button>
@@ -67,7 +66,7 @@ export default async function NewsletterTemplatesPage({ params }: PageProps) {
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Name</th> <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">Betreff</th>
<th className="p-3 text-left font-medium">Variablen</th> <th className="p-3 text-left font-medium">Variablen</th>
@@ -81,7 +80,7 @@ export default async function NewsletterTemplatesPage({ params }: PageProps) {
return ( return (
<tr <tr
key={String(template.id)} key={String(template.id)}
className="border-b hover:bg-muted/30" className="hover:bg-muted/30 border-b"
> >
<td className="p-3 font-medium"> <td className="p-3 font-medium">
{String(template.name ?? '—')} {String(template.name ?? '—')}
@@ -91,17 +90,19 @@ export default async function NewsletterTemplatesPage({ params }: PageProps) {
</td> </td>
<td className="p-3"> <td className="p-3">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{variables.length > 0 {variables.length > 0 ? (
? variables.map((v) => ( variables.map((v) => (
<Badge <Badge
key={v} key={v}
variant="secondary" variant="secondary"
className="text-xs" className="text-xs"
> >
{`{{${v}}}`} {`{{${v}}}`}
</Badge> </Badge>
)) ))
: <span className="text-muted-foreground"></span>} ) : (
<span className="text-muted-foreground"></span>
)}
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -13,9 +13,15 @@ import {
BedDouble, BedDouble,
} from 'lucide-react'; } from 'lucide-react';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { createCourseManagementApi } from '@kit/course-management/api';
import { createEventManagementApi } from '@kit/event-management/api';
import { createFinanceApi } from '@kit/finance/api';
import { createMemberManagementApi } from '@kit/member-management/api';
import { createNewsletterApi } from '@kit/newsletter/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { import {
Card, Card,
CardContent, CardContent,
@@ -24,17 +30,10 @@ import {
CardTitle, CardTitle,
} from '@kit/ui/card'; } from '@kit/ui/card';
import { createMemberManagementApi } from '@kit/member-management/api'; import { AccountNotFound } from '~/components/account-not-found';
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 { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card'; import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
interface TeamAccountHomePageProps { interface TeamAccountHomePageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -79,7 +78,12 @@ export default async function TeamAccountHomePage({
const courseStats = const courseStats =
courseStatsResult.status === 'fulfilled' courseStatsResult.status === 'fulfilled'
? courseStatsResult.value ? courseStatsResult.value
: { totalCourses: 0, openCourses: 0, completedCourses: 0, totalParticipants: 0 }; : {
totalCourses: 0,
openCourses: 0,
completedCourses: 0,
totalParticipants: 0,
};
const openInvoices = const openInvoices =
invoicesResult.status === 'fulfilled' ? invoicesResult.value : []; invoicesResult.status === 'fulfilled' ? invoicesResult.value : [];
@@ -145,82 +149,68 @@ export default async function TeamAccountHomePage({
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{/* Recent bookings */} {/* Recent bookings */}
{bookings.data.slice(0, 3).map((booking: Record<string, unknown>) => ( {bookings.data
<div .slice(0, 3)
key={String(booking.id)} .map((booking: Record<string, unknown>) => (
className="flex items-center justify-between rounded-md border p-3" <div
> key={String(booking.id)}
<div className="flex items-center gap-3"> className="flex items-center justify-between rounded-md border p-3"
<div className="rounded-full bg-blue-500/10 p-2 text-blue-600"> >
<BedDouble className="h-4 w-4" /> <div className="flex items-center gap-3">
</div> <div className="rounded-full bg-blue-500/10 p-2 text-blue-600">
<div> <BedDouble className="h-4 w-4" />
<Link </div>
href={`/home/${account}/bookings/${String(booking.id)}`} <div>
className="text-sm font-medium hover:underline" <Link
> href={`/home/${account}/bookings/${String(booking.id)}`}
Buchung{' '} className="text-sm font-medium hover:underline"
{booking.check_in >
? new Date( Buchung{' '}
String(booking.check_in), {booking.check_in
).toLocaleDateString('de-DE', { ? formatDate(booking.check_in as string)
day: '2-digit', : '—'}
month: 'short', </Link>
}) <p className="text-muted-foreground text-xs">
: ''} {formatDate(booking.check_in as string)} {' '}
</Link> {formatDate(booking.check_out as string)}
<p className="text-xs text-muted-foreground"> </p>
{booking.check_in </div>
? 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> </div>
<Badge variant="outline"> ))}
{String(booking.status ?? '—')}
</Badge>
</div>
))}
{/* Recent events */} {/* Recent events */}
{events.data.slice(0, 3).map((event: Record<string, unknown>) => ( {events.data
<div .slice(0, 3)
key={String(event.id)} .map((event: Record<string, unknown>) => (
className="flex items-center justify-between rounded-md border p-3" <div
> key={String(event.id)}
<div className="flex items-center gap-3"> className="flex items-center justify-between rounded-md border p-3"
<div className="rounded-full bg-amber-500/10 p-2 text-amber-600"> >
<CalendarDays className="h-4 w-4" /> <div className="flex items-center gap-3">
</div> <div className="rounded-full bg-amber-500/10 p-2 text-amber-600">
<div> <CalendarDays className="h-4 w-4" />
<Link </div>
href={`/home/${account}/events/${String(event.id)}`} <div>
className="text-sm font-medium hover:underline" <Link
> href={`/home/${account}/events/${String(event.id)}`}
{String(event.name)} className="text-sm font-medium hover:underline"
</Link> >
<p className="text-xs text-muted-foreground"> {String(event.name)}
{event.event_date </Link>
? new Date( <p className="text-muted-foreground text-xs">
String(event.event_date), {formatDate(event.event_date as string)}
).toLocaleDateString('de-DE') </p>
: 'Kein Datum'} </div>
</p>
</div> </div>
<Badge variant="outline">
{String(event.status ?? '—')}
</Badge>
</div> </div>
<Badge variant="outline"> ))}
{String(event.status ?? '—')}
</Badge>
</div>
))}
{bookings.data.length === 0 && events.data.length === 0 && ( {bookings.data.length === 0 && events.data.length === 0 && (
<EmptyState <EmptyState
@@ -242,7 +232,7 @@ export default async function TeamAccountHomePage({
<CardContent className="flex flex-col gap-2"> <CardContent className="flex flex-col gap-2">
<Link <Link
href={`/home/${account}/members-cms/new`} href={`/home/${account}/members-cms/new`}
className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all" className="border-border bg-background hover:bg-muted hover:text-foreground inline-flex w-full items-center justify-between gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-all"
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<UserPlus className="h-4 w-4" /> <UserPlus className="h-4 w-4" />
@@ -253,7 +243,7 @@ export default async function TeamAccountHomePage({
<Link <Link
href={`/home/${account}/courses/new`} href={`/home/${account}/courses/new`}
className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all" className="border-border bg-background hover:bg-muted hover:text-foreground inline-flex w-full items-center justify-between gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-all"
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<GraduationCap className="h-4 w-4" /> <GraduationCap className="h-4 w-4" />
@@ -264,7 +254,7 @@ export default async function TeamAccountHomePage({
<Link <Link
href={`/home/${account}/newsletter/new`} href={`/home/${account}/newsletter/new`}
className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all" className="border-border bg-background hover:bg-muted hover:text-foreground inline-flex w-full items-center justify-between gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-all"
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Mail className="h-4 w-4" /> <Mail className="h-4 w-4" />
@@ -275,7 +265,7 @@ export default async function TeamAccountHomePage({
<Link <Link
href={`/home/${account}/bookings/new`} href={`/home/${account}/bookings/new`}
className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all" className="border-border bg-background hover:bg-muted hover:text-foreground inline-flex w-full items-center justify-between gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-all"
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<BedDouble className="h-4 w-4" /> <BedDouble className="h-4 w-4" />
@@ -286,7 +276,7 @@ export default async function TeamAccountHomePage({
<Link <Link
href={`/home/${account}/events/new`} href={`/home/${account}/events/new`}
className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all" className="border-border bg-background hover:bg-muted hover:text-foreground inline-flex w-full items-center justify-between gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-all"
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
@@ -304,21 +294,23 @@ export default async function TeamAccountHomePage({
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-muted-foreground"> <p className="text-muted-foreground text-sm font-medium">
Buchungen Buchungen
</p> </p>
<p className="text-2xl font-bold">{bookings.total}</p> <p className="text-2xl font-bold">{bookings.total}</p>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs">
{bookings.data.filter( {
(b: Record<string, unknown>) => bookings.data.filter(
b.status === 'confirmed' || b.status === 'checked_in', (b: Record<string, unknown>) =>
).length}{' '} b.status === 'confirmed' || b.status === 'checked_in',
).length
}{' '}
aktiv aktiv
</p> </p>
</div> </div>
<Link <Link
href={`/home/${account}/bookings`} href={`/home/${account}/bookings`}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium hover:bg-muted hover:text-foreground transition-all" className="hover:bg-muted hover:text-foreground inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all"
> >
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Link> </Link>
@@ -330,22 +322,24 @@ export default async function TeamAccountHomePage({
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-muted-foreground"> <p className="text-muted-foreground text-sm font-medium">
Veranstaltungen Veranstaltungen
</p> </p>
<p className="text-2xl font-bold">{events.total}</p> <p className="text-2xl font-bold">{events.total}</p>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs">
{events.data.filter( {
(e: Record<string, unknown>) => events.data.filter(
e.status === 'published' || (e: Record<string, unknown>) =>
e.status === 'registration_open', e.status === 'published' ||
).length}{' '} e.status === 'registration_open',
).length
}{' '}
aktiv aktiv
</p> </p>
</div> </div>
<Link <Link
href={`/home/${account}/events`} href={`/home/${account}/events`}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium hover:bg-muted hover:text-foreground transition-all" className="hover:bg-muted hover:text-foreground inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all"
> >
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Link> </Link>
@@ -357,19 +351,19 @@ export default async function TeamAccountHomePage({
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-muted-foreground"> <p className="text-muted-foreground text-sm font-medium">
Kurse abgeschlossen Kurse abgeschlossen
</p> </p>
<p className="text-2xl font-bold"> <p className="text-2xl font-bold">
{courseStats.completedCourses} {courseStats.completedCourses}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs">
von {courseStats.totalCourses} insgesamt von {courseStats.totalCourses} insgesamt
</p> </p>
</div> </div>
<Link <Link
href={`/home/${account}/courses`} href={`/home/${account}/courses`}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium hover:bg-muted hover:text-foreground transition-all" className="hover:bg-muted hover:text-foreground inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all"
> >
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Link> </Link>

View File

@@ -1,19 +1,32 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createSiteBuilderApi } from '@kit/site-builder/api'; import { createSiteBuilderApi } from '@kit/site-builder/api';
import { SiteEditor } from '@kit/site-builder/components'; import { SiteEditor } from '@kit/site-builder/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
interface Props { params: Promise<{ account: string; pageId: string }> } interface Props {
params: Promise<{ account: string; pageId: string }>;
}
export default async function EditPageRoute({ params }: Props) { export default async function EditPageRoute({ params }: Props) {
const { account, pageId } = await params; const { account, pageId } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
const api = createSiteBuilderApi(client); const api = createSiteBuilderApi(client);
const page = await api.getPage(pageId); const page = await api.getPage(pageId);
if (!page) return <div>Seite nicht gefunden</div>; if (!page) return <AccountNotFound />;
return <SiteEditor pageId={pageId} accountId={acct.id} initialData={(page.puck_data ?? {}) as Record<string, unknown>} />; return (
<SiteEditor
pageId={pageId}
accountId={acct.id}
initialData={(page.puck_data ?? {}) as Record<string, unknown>}
/>
);
} }

View File

@@ -1,7 +1,8 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreatePageForm } from '@kit/site-builder/components'; import { CreatePageForm } from '@kit/site-builder/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -10,11 +11,19 @@ interface Props {
export default async function NewSitePage({ params }: Props) { export default async function NewSitePage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell account={account} title="Neue Seite" description="Seite für Ihre Vereinswebsite erstellen"> <CmsPageShell
account={account}
title="Neue Seite"
description="Seite für Ihre Vereinswebsite erstellen"
>
<CreatePageForm accountId={acct.id} account={account} /> <CreatePageForm accountId={acct.id} account={account} />
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -1,21 +1,31 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import Link from 'next/link';
import { Plus, Globe, FileText, Settings, ExternalLink } from 'lucide-react';
import { formatDate } from '@kit/shared/dates';
import { createSiteBuilderApi } from '@kit/site-builder/api'; import { createSiteBuilderApi } from '@kit/site-builder/api';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
import { Plus, Globe, FileText, Settings, ExternalLink } from 'lucide-react';
import Link from 'next/link'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { params: Promise<{ account: string }> } interface Props {
params: Promise<{ account: string }>;
}
export default async function SiteBuilderDashboard({ params }: Props) { export default async function SiteBuilderDashboard({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
const api = createSiteBuilderApi(client); const api = createSiteBuilderApi(client);
@@ -24,39 +34,72 @@ export default async function SiteBuilderDashboard({ params }: Props) {
const posts = await api.listPosts(acct.id); const posts = await api.listPosts(acct.id);
const isOnline = Boolean(settings?.is_public); const isOnline = Boolean(settings?.is_public);
const publishedCount = pages.filter((p: Record<string, unknown>) => p.is_published).length; const publishedCount = pages.filter(
(p: Record<string, unknown>) => p.is_published,
).length;
return ( return (
<CmsPageShell account={account} title="Website-Baukasten" description="Ihre Vereinswebseite verwalten"> <CmsPageShell
account={account}
title="Website-Baukasten"
description="Ihre Vereinswebseite verwalten"
>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex gap-2"> <div className="flex gap-2">
<Link href={`/home/${account}/site-builder/settings`}> <Link href={`/home/${account}/site-builder/settings`}>
<Button variant="outline" size="sm"><Settings className="mr-2 h-4 w-4" />Einstellungen</Button> <Button variant="outline" size="sm">
<Settings className="mr-2 h-4 w-4" />
Einstellungen
</Button>
</Link> </Link>
<Link href={`/home/${account}/site-builder/posts`}> <Link href={`/home/${account}/site-builder/posts`}>
<Button variant="outline" size="sm"><FileText className="mr-2 h-4 w-4" />Beiträge ({posts.length})</Button> <Button variant="outline" size="sm">
<FileText className="mr-2 h-4 w-4" />
Beiträge ({posts.length})
</Button>
</Link> </Link>
{isOnline && ( {isOnline && (
<a href={`/club/${account}`} target="_blank" rel="noopener"> <a href={`/club/${account}`} target="_blank" rel="noopener">
<Button variant="outline" size="sm"><ExternalLink className="mr-2 h-4 w-4" />Website ansehen</Button> <Button variant="outline" size="sm">
<ExternalLink className="mr-2 h-4 w-4" />
Website ansehen
</Button>
</a> </a>
)} )}
</div> </div>
<Link href={`/home/${account}/site-builder/new`}> <Link href={`/home/${account}/site-builder/new`}>
<Button><Plus className="mr-2 h-4 w-4" />Neue Seite</Button> <Button data-test="site-new-page-btn">
<Plus className="mr-2 h-4 w-4" />
Neue Seite
</Button>
</Link> </Link>
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Card><CardContent className="p-6"><p className="text-sm text-muted-foreground">Seiten</p><p className="text-2xl font-bold">{pages.length}</p></CardContent></Card>
<Card><CardContent className="p-6"><p className="text-sm text-muted-foreground">Veröffentlicht</p><p className="text-2xl font-bold">{publishedCount}</p></CardContent></Card>
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<p className="text-sm text-muted-foreground">Status</p> <p className="text-muted-foreground text-sm">Seiten</p>
<p className="text-2xl font-bold">{pages.length}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<p className="text-muted-foreground text-sm">Veröffentlicht</p>
<p className="text-2xl font-bold">{publishedCount}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<p className="text-muted-foreground text-sm">Status</p>
<p className="text-2xl font-bold"> <p className="text-2xl font-bold">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className={cn('inline-block h-2 w-2 rounded-full', isOnline ? 'bg-green-500' : 'bg-red-500')} /> <span
className={cn(
'inline-block h-2 w-2 rounded-full',
isOnline ? 'bg-green-500' : 'bg-red-500',
)}
/>
<span>{isOnline ? 'Online' : 'Offline'}</span> <span>{isOnline ? 'Online' : 'Offline'}</span>
</span> </span>
</p> </p>
@@ -75,25 +118,44 @@ export default async function SiteBuilderDashboard({ params }: Props) {
) : ( ) : (
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead><tr className="border-b bg-muted/50"> <thead>
<th className="p-3 text-left font-medium">Titel</th> <tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">URL</th> <th className="p-3 text-left font-medium">Titel</th>
<th className="p-3 text-left font-medium">Status</th> <th className="p-3 text-left font-medium">URL</th>
<th className="p-3 text-left font-medium">Startseite</th> <th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-left font-medium">Aktualisiert</th> <th className="p-3 text-left font-medium">Startseite</th>
<th className="p-3 text-left font-medium">Aktionen</th> <th className="p-3 text-left font-medium">Aktualisiert</th>
</tr></thead> <th className="p-3 text-left font-medium">Aktionen</th>
</tr>
</thead>
<tbody> <tbody>
{pages.map((page: Record<string, unknown>) => ( {pages.map((page: Record<string, unknown>) => (
<tr key={String(page.id)} className="border-b hover:bg-muted/30"> <tr
key={String(page.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">{String(page.title)}</td> <td className="p-3 font-medium">{String(page.title)}</td>
<td className="p-3 text-muted-foreground font-mono text-xs">/{String(page.slug)}</td> <td className="text-muted-foreground p-3 font-mono text-xs">
<td className="p-3"><Badge variant={page.is_published ? 'default' : 'secondary'}>{page.is_published ? 'Veröffentlicht' : 'Entwurf'}</Badge></td> /{String(page.slug)}
<td className="p-3">{page.is_homepage ? '⭐' : '—'}</td> </td>
<td className="p-3 text-xs text-muted-foreground">{page.updated_at ? new Date(String(page.updated_at)).toLocaleDateString('de-DE') : '—'}</td>
<td className="p-3"> <td className="p-3">
<Link href={`/home/${account}/site-builder/${String(page.id)}/edit`}> <Badge
<Button size="sm" variant="outline">Bearbeiten</Button> variant={page.is_published ? 'default' : 'secondary'}
>
{page.is_published ? 'Veröffentlicht' : 'Entwurf'}
</Badge>
</td>
<td className="p-3">{page.is_homepage ? '⭐' : '—'}</td>
<td className="text-muted-foreground p-3 text-xs">
{formatDate(page.updated_at)}
</td>
<td className="p-3">
<Link
href={`/home/${account}/site-builder/${String(page.id)}/edit`}
>
<Button size="sm" variant="outline">
Bearbeiten
</Button>
</Link> </Link>
</td> </td>
</tr> </tr>

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