Compare commits

..

4 Commits

Author SHA1 Message Date
Zaid Marzguioui
1687735de0 fix: merge upstream, fix locale duplicate, add missing catalog deps
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 12m20s
Workflow / ⚫️ Test (push) Has been skipped
- Merged upstream/main (MakerKit latest fixes)
- Fixed locales.tsx: removed hardcoded 'de' duplicate (defaultLocale already = 'de')
- Added missing pnpm catalog entries for custom packages:
  @measured/puck, @react-pdf/renderer, @tiptap/*, exceljs, iban, papaparse
- CACHE_BUST=7 for full rebuild
2026-04-01 11:02:06 +02:00
Zaid Marzguioui
8d9d62ca56 merge: upstream/main — latest MakerKit fixes and dependency updates 2026-04-01 10:56:45 +02:00
Giancarlo Buomprisco
c837d4f592 chore: bump version to 3.1.1 in package.json; refactor mobile navigation components and improve layout structure (#472)
- Updated version to 3.1.1 in package.json.
- Refactored mobile navigation components to enhance structure and usability.
- Adjusted layout components to improve responsiveness and visual consistency.
- Introduced shared mobile navigation components for better code reuse.
2026-03-31 21:24:37 +08:00
Giancarlo Buomprisco
6268d1bab0 Updated dependencies, Added Hosted mode for Stripe checkout
* chore: bump version to 3.1.0 and update dependencies in package.json, pnpm-lock.yaml, and pnpm-workspace.yaml; enhance billing services with support for hosted checkout page in Stripe integration

* Enhance error handling in billing services to log error messages instead of objects; update documentation for Stripe integration to clarify publishable key requirements based on UI mode.
2026-03-31 12:44:30 +08:00
330 changed files with 5455 additions and 23939 deletions

View File

@@ -44,12 +44,6 @@ 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,7 @@ WORKDIR /app
# --- Install + Build in one stage --- # --- Install + Build in one stage ---
FROM base AS builder FROM base AS builder
ARG CACHE_BUST=6 ARG CACHE_BUST=7
COPY . . COPY . .
RUN pnpm install --no-frozen-lockfile RUN pnpm install --no-frozen-lockfile
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1

View File

@@ -4,9 +4,7 @@
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 ({ test('create course, enroll participant, check capacity, waitlist', async ({ page }) => {
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,9 +15,7 @@ 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 ({ test('application workflow: submit → review → approve → member created', async ({ page }) => {
page,
}) => {
// Submit application // Submit application
// Review application // Review application
// Approve → verify member auto-created // Approve → verify member auto-created

View File

@@ -4,9 +4,7 @@
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 ({ test('create module, add fields, insert record, query, update, soft-delete', async ({ page }) => {
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,9 +4,7 @@
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 ({ test('create campaign, select recipients from members, preview, send', async ({ page }) => {
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,9 +4,7 @@
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 ({ test('create SEPA direct debit batch, add items, generate XML', async ({ page }) => {
page,
}) => {
// Create batch // Create batch
// Add items with valid IBANs // Add items with valid IBANs
// Generate XML // Generate XML

View File

@@ -18,6 +18,7 @@ EMAIL_PASSWORD=password
# STRIPE # STRIPE
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51K9cWKI1i3VnbZTq2HGstY2S8wt3peF1MOqPXFO4LR8ln2QgS7GxL8XyKaKLvn7iFHeqAnvdDw0o48qN7rrwwcHU00jOtKhjsf NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51K9cWKI1i3VnbZTq2HGstY2S8wt3peF1MOqPXFO4LR8ln2QgS7GxL8XyKaKLvn7iFHeqAnvdDw0o48qN7rrwwcHU00jOtKhjsf
STRIPE_UI_MODE=embedded_page # TESTS ONLY SUPPORT THIS MODE, KEEP AS IS
CONTACT_EMAIL=test@makerkit.dev CONTACT_EMAIL=test@makerkit.dev

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 tracking-widest uppercase"> <p className="text-muted-foreground text-sm font-medium uppercase tracking-widest">
<Trans i18nKey={'marketing.trustedBy'} /> <Trans i18nKey={'marketing.trustedBy'} />
</p> </p>
@@ -89,7 +89,10 @@ function Home() {
label="marketing.trustSchools" label="marketing.trustSchools"
/> />
<TrustItem icon={BookOpenIcon} label="marketing.trustClubs" /> <TrustItem icon={BookOpenIcon} label="marketing.trustClubs" />
<TrustItem icon={GlobeIcon} label="marketing.trustOrganizations" /> <TrustItem
icon={GlobeIcon}
label="marketing.trustOrganizations"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -181,7 +184,9 @@ 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 i18nKey={'marketing.additionalFeaturesSubheading'} /> <Trans
i18nKey={'marketing.additionalFeaturesSubheading'}
/>
</span> </span>
</> </>
} }
@@ -251,7 +256,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 xl:text-5xl dark:text-white"> <h2 className="text-3xl font-medium tracking-tight dark:text-white xl:text-5xl">
<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">
@@ -311,7 +316,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 xl:text-5xl dark:text-white"> <h2 className="max-w-3xl text-3xl font-medium tracking-tight dark:text-white xl:text-5xl">
<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,9 +9,10 @@ export default async function AdminAuditPage() {
</div> </div>
<div className="rounded-lg border p-6"> <div className="rounded-lg border p-6">
<p className="text-muted-foreground text-sm"> <p className="text-sm text-muted-foreground">
Alle Datenänderungen (Erstellen, Ändern, Löschen, Sperren) über alle Alle Datenänderungen (Erstellen, Ändern, Löschen, Sperren)
Mandanten hinweg. Filtert nach Zeitraum, Benutzer, Tabelle und Aktion. über alle Mandanten hinweg. Filtert nach Zeitraum, Benutzer,
Tabelle und Aktion.
</p> </p>
</div> </div>
</div> </div>

View File

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

View File

@@ -8,27 +8,23 @@ export default async function AdminMigrationPage() {
</p> </p>
</div> </div>
<div className="space-y-4 rounded-lg border p-6"> <div className="rounded-lg border p-6 space-y-4">
<h2 className="text-lg font-semibold">Migrationsschritte</h2> <h2 className="text-lg font-semibold">Migrationsschritte</h2>
<ol className="list-inside list-decimal space-y-2 text-sm"> <ol className="list-decimal list-inside 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> <li>Module (m_module/m_modulfeld modules/module_fields) übertragen</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 border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950"> <div className="rounded-md bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 p-4">
<p className="text-sm text-amber-800 dark:text-amber-200"> <p className="text-sm text-amber-800 dark:text-amber-200">
<strong>Hinweis:</strong> Die Migration erfordert eine <strong>Hinweis:</strong> Die Migration erfordert eine MySQL-Verbindung zum Legacy-System.
MySQL-Verbindung zum Legacy-System. Stellen Sie sicher, dass{' '} Stellen Sie sicher, dass <code>mysql2</code> installiert ist und die Verbindungsdaten korrekt konfiguriert sind.
<code>mysql2</code> installiert ist und die Verbindungsdaten korrekt
konfiguriert sind.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -9,10 +9,9 @@ export default async function AdminModulesPage() {
</div> </div>
<div className="rounded-lg border p-6"> <div className="rounded-lg border p-6">
<p className="text-muted-foreground text-sm"> <p className="text-sm text-muted-foreground">
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 Ermöglicht die zentrale Verwaltung von Modulvorlagen und -konfigurationen.
-konfigurationen.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,13 +1,9 @@
import { notFound } from 'next/navigation';
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
import { notFound } from 'next/navigation';
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 { interface Props { params: Promise<{ slug: string; page: string[] }> }
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;
@@ -18,73 +14,36 @@ 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 const { data: account } = await supabase.from('accounts').select('id').eq('slug', slug).single();
.from('accounts')
.select('id')
.eq('slug', slug)
.single();
if (!account) notFound(); if (!account) notFound();
const { data: settings } = await supabase const { data: settings } = await supabase.from('site_settings').select('*').eq('account_id', account.id).eq('is_public', true).maybeSingle();
.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 const { data: sitePageData } = await supabase.from('site_pages').select('*')
.from('site_pages') .eq('account_id', account.id).eq('slug', pageSlug).eq('is_published', true).maybeSingle();
.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 supabase.from('events').select('id, name, event_date, event_time, location, fee, status')
.from('events') .eq('account_id', account.id).order('event_date', { ascending: true }).limit(20),
.select('id, name, event_date, event_time, location, fee, status') supabase.from('courses').select('id, name, start_date, end_date, fee, capacity, status')
.eq('account_id', account.id) .eq('account_id', account.id).order('start_date', { ascending: true }).limit(20),
.order('event_date', { ascending: true }) supabase.from('cms_posts').select('id, title, excerpt, cover_image, published_at, slug')
.limit(20), .eq('account_id', account.id).eq('status', 'published').order('published_at', { ascending: false }).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 <div style={{ '--primary': settings.primary_color, fontFamily: settings.font_family } as React.CSSProperties}>
style={ <SiteRenderer data={(sitePageData.puck_data ?? {}) as Record<string, unknown>} siteData={siteData} />
{
'--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,28 +1,23 @@
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 { interface Props { params: Promise<{ slug: string }> }
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="bg-muted/30 flex min-h-screen items-center justify-center p-6"> <div className="min-h-screen bg-muted/30 flex 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="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full"> <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Mail className="text-primary h-6 w-6" /> <Mail className="h-6 w-6 text-primary" />
</div> </div>
<CardTitle>Newsletter abonnieren</CardTitle> <CardTitle>Newsletter abonnieren</CardTitle>
<p className="text-muted-foreground text-sm"> <p className="text-sm text-muted-foreground">Bleiben Sie über Neuigkeiten informiert.</p>
Bleiben Sie über Neuigkeiten informiert.
</p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form className="space-y-4"> <form className="space-y-4">
@@ -32,19 +27,11 @@ 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 <Input name="email" type="email" placeholder="ihre@email.de" required />
name="email"
type="email"
placeholder="ihre@email.de"
required
/>
</div> </div>
<Button type="submit" className="w-full"> <Button type="submit" className="w-full">Abonnieren</Button>
Abonnieren <p className="text-xs text-center text-muted-foreground">
</Button> Sie können sich jederzeit abmelden. Wir senden Ihnen eine Bestätigungs-E-Mail.
<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,51 +1,34 @@
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';
import { MailX } from 'lucide-react'; interface Props { params: Promise<{ slug: string }>; searchParams: Promise<{ token?: string }> }
import { Button } from '@kit/ui/button'; export default async function NewsletterUnsubscribePage({ params, searchParams }: Props) {
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="bg-muted/30 flex min-h-screen items-center justify-center p-6"> <div className="min-h-screen bg-muted/30 flex 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="bg-destructive/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full"> <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<MailX className="text-destructive h-6 w-6" /> <MailX className="h-6 w-6 text-destructive" />
</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-muted-foreground text-sm"> <p className="text-sm text-muted-foreground">Möchten Sie den Newsletter wirklich abbestellen?</p>
Möchten Sie den Newsletter wirklich abbestellen? <Button variant="destructive" className="w-full">Abbestellen bestätigen</Button>
</p>
<Button variant="destructive" className="w-full">
Abbestellen bestätigen
</Button>
</> </>
) : ( ) : (
<p className="text-muted-foreground text-sm"> <p className="text-sm text-muted-foreground">Kein gültiger Abmeldelink. Bitte verwenden Sie den Link aus der Newsletter-E-Mail.</p>
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"> <Button variant="outline" size="sm"> Zurück zur Website</Button>
Zurück zur Website
</Button>
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,13 +1,9 @@
import { notFound } from 'next/navigation';
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
import { notFound } from 'next/navigation';
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 { interface Props { params: Promise<{ slug: string }> }
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;
@@ -17,74 +13,36 @@ 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 const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
.from('accounts')
.select('id, name')
.eq('slug', slug)
.single();
if (!account) notFound(); if (!account) notFound();
const { data: settings } = await supabase const { data: settings } = await supabase.from('site_settings').select('*').eq('account_id', account.id).eq('is_public', true).maybeSingle();
.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 const { data: page } = await supabase.from('site_pages').select('*')
.from('site_pages') .eq('account_id', account.id).eq('is_homepage', true).eq('is_published', true).maybeSingle();
.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 supabase.from('events').select('id, name, event_date, event_time, location, fee, status')
.from('events') .eq('account_id', account.id).order('event_date', { ascending: true }).limit(20),
.select('id, name, event_date, event_time, location, fee, status') supabase.from('courses').select('id, name, start_date, end_date, fee, capacity, status')
.eq('account_id', account.id) .eq('account_id', account.id).order('start_date', { ascending: true }).limit(20),
.order('event_date', { ascending: true }) supabase.from('cms_posts').select('id, title, excerpt, cover_image, published_at, slug')
.limit(20), .eq('account_id', account.id).eq('status', 'published').order('published_at', { ascending: false }).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 <div style={{ '--primary': settings.primary_color, '--secondary': settings.secondary_color, fontFamily: settings.font_family } as React.CSSProperties}>
style={ <SiteRenderer data={(page.puck_data ?? {}) as Record<string, unknown>} siteData={siteData} />
{
'--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,13 +1,9 @@
import Link from 'next/link';
import { createClient } from '@supabase/supabase-js'; 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'; 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';
interface Props { interface Props {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
@@ -18,117 +14,77 @@ 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.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
); );
const { data: account } = await supabase const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
.from('accounts') if (!account) return <div className="p-8 text-center">Organisation nicht gefunden</div>;
.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: '1', { id: '2', title: 'Mitgliedsbeitrag 2025', type: 'Rechnung', date: '2025-01-10', status: 'paid' },
title: 'Mitgliedsbeitrag 2026', { id: '3', title: 'Beitrittserklärung', type: 'Dokument', date: '2020-01-15', status: 'signed' },
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': case 'paid': return <Badge variant="default">Bezahlt</Badge>;
return <Badge variant="default">Bezahlt</Badge>; case 'open': return <Badge variant="secondary">Offen</Badge>;
case 'open': case 'signed': return <Badge variant="outline">Unterschrieben</Badge>;
return <Badge variant="secondary">Offen</Badge>; default: return <Badge variant="secondary">{status}</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': case 'Rechnung': return <Receipt className="h-5 w-5 text-primary" />;
return <Receipt className="text-primary h-5 w-5" />; case 'Dokument': return <FileCheck className="h-5 w-5 text-primary" />;
case 'Dokument': default: return <FileText className="h-5 w-5 text-primary" />;
return <FileCheck className="text-primary h-5 w-5" />;
default:
return <FileText className="text-primary h-5 w-5" />;
} }
}; };
return ( return (
<div className="bg-muted/30 min-h-screen"> <div className="min-h-screen bg-muted/30">
<header className="bg-background border-b px-6 py-4"> <header className="border-b bg-background px-6 py-4">
<div className="mx-auto flex max-w-4xl items-center justify-between"> <div className="flex items-center justify-between max-w-4xl mx-auto">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Shield className="text-primary h-5 w-5" /> <Shield className="h-5 w-5 text-primary" />
<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"> <Button variant="ghost" size="sm"> Zurück zum Portal</Button>
Zurück zum Portal
</Button>
</Link> </Link>
</div> </div>
</header> </header>
<main className="mx-auto max-w-3xl px-6 py-8"> <main className="max-w-3xl mx-auto py-8 px-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Verfügbare Dokumente</CardTitle> <CardTitle>Verfügbare Dokumente</CardTitle>
<p className="text-muted-foreground text-sm"> <p className="text-sm text-muted-foreground">{String(account.name)} Dokumente und Rechnungen</p>
{String(account.name)} Dokumente und Rechnungen
</p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{documents.length === 0 ? ( {documents.length === 0 ? (
<div className="text-muted-foreground py-8 text-center"> <div className="text-center py-8 text-muted-foreground">
<FileText className="mx-auto mb-3 h-10 w-10" /> <FileText className="mx-auto h-10 w-10 mb-3" />
<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 <div key={doc.id} className="flex items-center justify-between rounded-lg border p-4 hover:bg-muted/30 transition-colors">
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="text-sm font-medium">{doc.title}</p> <p className="font-medium text-sm">{doc.title}</p>
<p className="text-muted-foreground text-xs"> <p className="text-xs text-muted-foreground">{doc.type} {new Date(doc.date).toLocaleDateString('de-DE')}</p>
{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="mr-1 h-3 w-3" /> <Download className="h-3 w-3 mr-1" />
PDF PDF
</Button> </Button>
</div> </div>

View File

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

View File

@@ -1,12 +1,10 @@
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 }>;
@@ -20,23 +18,15 @@ 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 const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
.from('accounts') if (!account) return <div className="p-8 text-center">Organisation nicht gefunden</div>;
.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 { const { data: { user } } = await supabase.auth.getUser();
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 const { data: member } = await supabase.from('members')
.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)
@@ -45,61 +35,45 @@ 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="bg-muted/30 min-h-screen"> <div className="min-h-screen bg-muted/30">
<header className="bg-background border-b px-6 py-4"> <header className="border-b bg-background px-6 py-4">
<div className="mx-auto flex max-w-4xl items-center justify-between"> <div className="flex items-center justify-between max-w-4xl mx-auto">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Shield className="text-primary h-5 w-5" /> <Shield className="h-5 w-5 text-primary" />
<h1 className="text-lg font-bold"> <h1 className="text-lg font-bold">Mitgliederbereich {String(account.name)}</h1>
Mitgliederbereich {String(account.name)}
</h1>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-muted-foreground text-sm"> <span className="text-sm text-muted-foreground">{String(member.first_name)} {String(member.last_name)}</span>
{String(member.first_name)} {String(member.last_name)} <Link href={`/club/${slug}`}><Button variant="ghost" size="sm"> Website</Button></Link>
</span>
<Link href={`/club/${slug}`}>
<Button variant="ghost" size="sm">
Website
</Button>
</Link>
</div> </div>
</div> </div>
</header> </header>
<main className="mx-auto max-w-4xl px-6 py-12"> <main className="max-w-4xl mx-auto py-12 px-6">
<h2 className="mb-6 text-2xl font-bold"> <h2 className="text-2xl font-bold mb-6">Willkommen, {String(member.first_name)}!</h2>
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 cursor-pointer transition-colors"> <Card className="hover:border-primary transition-colors cursor-pointer">
<CardContent className="p-6 text-center"> <CardContent className="p-6 text-center">
<UserCircle className="text-primary mx-auto mb-3 h-10 w-10" /> <UserCircle className="mx-auto h-10 w-10 text-primary mb-3" />
<h3 className="font-semibold">Mein Profil</h3> <h3 className="font-semibold">Mein Profil</h3>
<p className="text-muted-foreground mt-1 text-xs"> <p className="text-xs text-muted-foreground mt-1">Kontaktdaten und Datenschutz</p>
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 cursor-pointer transition-colors"> <Card className="hover:border-primary transition-colors cursor-pointer">
<CardContent className="p-6 text-center"> <CardContent className="p-6 text-center">
<FileText className="text-primary mx-auto mb-3 h-10 w-10" /> <FileText className="mx-auto h-10 w-10 text-primary mb-3" />
<h3 className="font-semibold">Dokumente</h3> <h3 className="font-semibold">Dokumente</h3>
<p className="text-muted-foreground mt-1 text-xs"> <p className="text-xs text-muted-foreground mt-1">Rechnungen und Bescheinigungen</p>
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="text-primary mx-auto mb-3 h-10 w-10" /> <CreditCard className="mx-auto h-10 w-10 text-primary mb-3" />
<h3 className="font-semibold">Mitgliedsausweis</h3> <h3 className="font-semibold">Mitgliedsausweis</h3>
<p className="text-muted-foreground mt-1 text-xs"> <p className="text-xs text-muted-foreground mt-1">Digital anzeigen</p>
Digital anzeigen
</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -111,18 +85,14 @@ 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="bg-muted/30 min-h-screen"> <div className="min-h-screen bg-muted/30">
<header className="bg-background border-b px-6 py-4"> <header className="border-b bg-background px-6 py-4">
<div className="mx-auto flex max-w-4xl items-center justify-between"> <div className="flex items-center justify-between max-w-4xl mx-auto">
<h1 className="text-lg font-bold">Mitgliederbereich</h1> <h1 className="text-lg font-bold">Mitgliederbereich</h1>
<Link href={`/club/${slug}`}> <Link href={`/club/${slug}`}><Button variant="ghost" size="sm"> Zurück zur Website</Button></Link>
<Button variant="ghost" size="sm">
Zurück zur Website
</Button>
</Link>
</div> </div>
</header> </header>
<main className="mx-auto max-w-4xl px-6 py-12"> <main className="max-w-4xl mx-auto py-12 px-6">
<PortalLoginForm slug={slug} accountName={String(account.name)} /> <PortalLoginForm slug={slug} accountName={String(account.name)} />
</main> </main>
</div> </div>

View File

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

View File

@@ -22,13 +22,15 @@ export function HomeAccountSelector(props: {
}>; }>;
userId: string; userId: string;
collapsed?: boolean;
}) { }) {
const router = useRouter(); const router = useRouter();
const context = useContext(SidebarContext); const context = useContext(SidebarContext);
const collapsed = props.collapsed ?? !context?.open;
return ( return (
<AccountSelector <AccountSelector
collapsed={!context?.open} collapsed={collapsed}
accounts={props.accounts} accounts={props.accounts}
features={features} features={features}
userId={props.userId} userId={props.userId}

View File

@@ -39,7 +39,9 @@ export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
return ( return (
<div className={'flex w-full flex-1 justify-between'}> <div className={'flex w-full flex-1 justify-between'}>
<div className={'flex items-center space-x-8'}> <div className={'flex items-center space-x-8'}>
<div>
<AppLogo /> <AppLogo />
</div>
<BorderedNavigationMenu> <BorderedNavigationMenu>
{routes.map((route) => ( {routes.map((route) => (
@@ -54,7 +56,9 @@ export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
</If> </If>
<If condition={featuresFlagConfig.enableTeamAccounts}> <If condition={featuresFlagConfig.enableTeamAccounts}>
<div>
<HomeAccountSelector userId={user.id} accounts={accounts} /> <HomeAccountSelector userId={user.id} accounts={accounts} />
</div>
</If> </If>
<div> <div>

View File

@@ -1,15 +1,12 @@
'use client'; 'use client';
import Link from 'next/link'; import { Menu } from 'lucide-react';
import { LogOut, Menu } from 'lucide-react';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
@@ -17,6 +14,10 @@ import {
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import {
MobileNavRouteLinks,
MobileNavSignOutItem,
} from '~/components/mobile-navigation-shared';
import featuresFlagConfig from '~/config/feature-flags.config'; import featuresFlagConfig from '~/config/feature-flags.config';
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config'; import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
@@ -27,25 +28,6 @@ import type { UserWorkspace } from '../_lib/server/load-user-workspace';
export function HomeMobileNavigation(props: { workspace: UserWorkspace }) { export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
const signOut = useSignOut(); const signOut = useSignOut();
const Links = personalAccountNavigationConfig.routes.map((item, index) => {
if ('children' in item) {
return item.children.map((child) => {
return (
<DropdownLink
key={child.path}
Icon={child.Icon}
path={child.path}
label={child.label}
/>
);
});
}
if ('divider' in item) {
return <DropdownMenuSeparator key={index} />;
}
});
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
@@ -60,6 +42,7 @@ export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
</DropdownMenuLabel> </DropdownMenuLabel>
<HomeAccountSelector <HomeAccountSelector
collapsed={false}
userId={props.workspace.user.id} userId={props.workspace.user.id}
accounts={props.workspace.accounts} accounts={props.workspace.accounts}
/> />
@@ -68,57 +51,16 @@ export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</If> </If>
<DropdownMenuGroup>{Links}</DropdownMenuGroup> <DropdownMenuGroup>
<MobileNavRouteLinks
routes={personalAccountNavigationConfig.routes}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} /> <MobileNavSignOutItem onSignOut={() => signOut.mutateAsync()} />
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
} }
function DropdownLink(
props: React.PropsWithChildren<{
path: string;
label: string;
Icon: React.ReactNode;
}>,
) {
return (
<DropdownMenuItem
render={
<Link
href={props.path}
className={'flex h-12 w-full items-center space-x-4'}
>
{props.Icon}
<span>
<Trans i18nKey={props.label} defaults={props.label} />
</span>
</Link>
}
key={props.path}
/>
);
}
function SignOutDropdownItem(
props: React.PropsWithChildren<{
onSignOut: () => unknown;
}>,
) {
return (
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-4'}
onClick={props.onSignOut}
>
<LogOut className={'h-6'} />
<span>
<Trans i18nKey={'common.signOut'} defaults={'Sign out'} />
</span>
</DropdownMenuItem>
);
}

View File

@@ -1,12 +1,26 @@
import { cookies } from 'next/headers';
import { PageHeader } from '@kit/ui/page'; import { PageHeader } from '@kit/ui/page';
export function HomeLayoutPageHeader( import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
export async function HomeLayoutPageHeader(
props: React.PropsWithChildren<{ props: React.PropsWithChildren<{
title: string | React.ReactNode; title: string | React.ReactNode;
description: string | React.ReactNode; description: string | React.ReactNode;
}>, }>,
) { ) {
const cookieStore = await cookies();
const layoutStyleCookie = cookieStore.get('layout-style')?.value;
const displaySidebarTrigger =
(layoutStyleCookie ?? personalAccountNavigationConfig.style) === 'sidebar';
return ( return (
<PageHeader description={props.description}>{props.children}</PageHeader> <PageHeader
description={props.description}
displaySidebarTrigger={displaySidebarTrigger}
>
{props.children}
</PageHeader>
); );
} }

View File

@@ -1,4 +1,6 @@
import 'server-only'; import 'server-only';
import { redirect } from 'next/navigation';
import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseClient } from '@supabase/supabase-js';
import * as z from 'zod'; import * as z from 'zod';
@@ -81,9 +83,12 @@ class UserBillingService {
`User requested a personal account checkout session. Contacting provider...`, `User requested a personal account checkout session. Contacting provider...`,
); );
let checkoutToken: string | null | undefined;
let url: string | null | undefined;
try { try {
// call the payment gateway to create the checkout session // call the payment gateway to create the checkout session
const { checkoutToken } = await service.createCheckoutSession({ const checkout = await service.createCheckoutSession({
returnUrl, returnUrl,
accountId, accountId,
customerEmail: user.email, customerEmail: user.email,
@@ -93,6 +98,45 @@ class UserBillingService {
enableDiscountField: product.enableDiscountField, enableDiscountField: product.enableDiscountField,
}); });
checkoutToken = checkout.checkoutToken;
url = checkout.url;
} catch (error) {
const message = Error.isError(error) ? error.message : error;
logger.error(
{
name: `billing.personal-account`,
planId,
customerId,
accountId,
error: message,
},
`Checkout session not created due to an error`,
);
throw new Error(`Failed to create a checkout session`, { cause: error });
}
if (!url && !checkoutToken) {
throw new Error(
'Checkout session returned neither a URL nor a checkout token',
);
}
// if URL provided, we redirect to the provider's hosted page
if (url) {
logger.info(
{
userId: user.id,
},
`Checkout session created. Redirecting to hosted page...`,
);
redirect(url);
}
// return the checkout token to the client
// so we can call the payment gateway to complete the checkout
logger.info( logger.info(
{ {
userId: user.id, userId: user.id,
@@ -100,25 +144,9 @@ class UserBillingService {
`Checkout session created. Returning checkout token to client...`, `Checkout session created. Returning checkout token to client...`,
); );
// return the checkout token to the client
// so we can call the payment gateway to complete the checkout
return { return {
checkoutToken, checkoutToken,
}; };
} catch (error) {
logger.error(
{
name: `billing.personal-account`,
planId,
customerId,
accountId,
error,
},
`Checkout session not created due to an error`,
);
throw new Error(`Failed to create a checkout session`, { cause: error });
}
} }
/** /**

View File

@@ -52,7 +52,7 @@ async function SidebarLayout({ children }: React.PropsWithChildren) {
<HomeSidebar workspace={workspace} /> <HomeSidebar workspace={workspace} />
</PageNavigation> </PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}> <PageMobileNavigation>
<MobileNavigation workspace={workspace} /> <MobileNavigation workspace={workspace} />
</PageMobileNavigation> </PageMobileNavigation>
@@ -75,7 +75,7 @@ async function HeaderLayout({ children }: React.PropsWithChildren) {
<HomeMenuNavigation workspace={workspace} /> <HomeMenuNavigation workspace={workspace} />
</PageNavigation> </PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}> <PageMobileNavigation>
<MobileNavigation workspace={workspace} /> <MobileNavigation workspace={workspace} />
</PageMobileNavigation> </PageMobileNavigation>
@@ -92,7 +92,9 @@ function MobileNavigation({
}) { }) {
return ( return (
<> <>
<div>
<AppLogo /> <AppLogo />
</div>
<HomeMobileNavigation workspace={workspace} /> <HomeMobileNavigation workspace={workspace} />
</> </>

View File

@@ -1,9 +1,11 @@
'use client'; 'use client';
import { useContext } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { AccountSelector } from '@kit/accounts/account-selector'; import { AccountSelector } from '@kit/accounts/account-selector';
import { useSidebar } from '@kit/ui/sidebar'; import { SidebarContext } from '@kit/ui/sidebar';
import featureFlagsConfig from '~/config/feature-flags.config'; import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config'; import pathsConfig from '~/config/paths.config';
@@ -23,7 +25,7 @@ export function TeamAccountAccountsSelector(params: {
}>; }>;
}) { }) {
const router = useRouter(); const router = useRouter();
const ctx = useSidebar(); const ctx = useContext(SidebarContext);
return ( return (
<AccountSelector <AccountSelector

View File

@@ -1,32 +1,28 @@
'use client'; 'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Home, LogOut, Menu } from 'lucide-react'; import { Menu } from 'lucide-react';
import * as z from 'zod';
import { AccountSelector } from '@kit/accounts/account-selector'; import { AccountSelector } from '@kit/accounts/account-selector';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu'; } from '@kit/ui/dropdown-menu';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import {
MobileNavRouteLinks,
MobileNavSignOutItem,
} from '~/components/mobile-navigation-shared';
import featureFlagsConfig from '~/config/feature-flags.config'; import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config'; import pathsConfig from '~/config/paths.config';
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
type Accounts = Array<{ type Accounts = Array<{
label: string | null; label: string | null;
@@ -35,7 +31,6 @@ type Accounts = Array<{
}>; }>;
const features = { const features = {
enableTeamAccounts: featureFlagsConfig.enableTeamAccounts,
enableTeamCreation: featureFlagsConfig.enableTeamCreation, enableTeamCreation: featureFlagsConfig.enableTeamCreation,
}; };
@@ -44,129 +39,23 @@ export const TeamAccountLayoutMobileNavigation = (
account: string; account: string;
userId: string; userId: string;
accounts: Accounts; accounts: Accounts;
config: z.output<typeof NavigationConfigSchema>;
}>, }>,
) => { ) => {
const router = useRouter();
const signOut = useSignOut(); const signOut = useSignOut();
const Links = props.config.routes.map((item, index) => {
if ('children' in item) {
return item.children.map((child) => {
return (
<DropdownLink
key={child.path}
Icon={child.Icon}
path={child.path}
label={child.label}
/>
);
});
}
if ('divider' in item) {
return <DropdownMenuSeparator key={index} />;
}
});
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<Menu className={'h-9'} /> <Menu className={'h-9'} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}> <DropdownMenuContent className={'w-screen rounded-none'}>
<TeamAccountsModal <DropdownMenuGroup>
userId={props.userId} <DropdownMenuLabel>
accounts={props.accounts}
account={props.account}
/>
{Links}
<DropdownMenuSeparator />
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
</DropdownMenuContent>
</DropdownMenu>
);
};
function DropdownLink(
props: React.PropsWithChildren<{
path: string;
label: string;
Icon: React.ReactNode;
}>,
) {
return (
<DropdownMenuItem
render={
<Link
href={props.path}
className={'flex h-12 w-full items-center gap-x-3 px-3'}
>
{props.Icon}
<span>
<Trans i18nKey={props.label} defaults={props.label} />
</span>
</Link>
}
/>
);
}
function SignOutDropdownItem(
props: React.PropsWithChildren<{
onSignOut: () => unknown;
}>,
) {
return (
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-2'}
onClick={props.onSignOut}
>
<LogOut className={'h-4'} />
<span>
<Trans i18nKey={'common.signOut'} />
</span>
</DropdownMenuItem>
);
}
function TeamAccountsModal(props: {
accounts: Accounts;
userId: string;
account: string;
}) {
const router = useRouter();
return (
<Dialog>
<DialogTrigger
render={
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-2'}
onSelect={(e) => e.preventDefault()}
>
<Home className={'h-4'} />
<span>
<Trans i18nKey={'common.yourAccounts'} /> <Trans i18nKey={'common.yourAccounts'} />
</span> </DropdownMenuLabel>
</DropdownMenuItem>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'common.yourAccounts'} />
</DialogTitle>
</DialogHeader>
<div className={'py-6'}>
<AccountSelector <AccountSelector
className={'w-full max-w-full'} className={'w-full max-w-full'}
userId={props.userId} userId={props.userId}
@@ -185,8 +74,20 @@ function TeamAccountsModal(props: {
router.replace(path); router.replace(path);
}} }}
/> />
</div> </DropdownMenuGroup>
</DialogContent>
</Dialog> <DropdownMenuSeparator />
<DropdownMenuGroup>
<MobileNavRouteLinks
routes={getTeamAccountSidebarConfig(props.account).routes}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<MobileNavSignOutItem onSignOut={() => signOut.mutateAsync()} />
</DropdownMenuContent>
</DropdownMenu>
); );
} };

View File

@@ -1,13 +1,28 @@
import { cookies } from 'next/headers';
import { PageHeader } from '@kit/ui/page'; import { PageHeader } from '@kit/ui/page';
export function TeamAccountLayoutPageHeader( import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
export async function TeamAccountLayoutPageHeader(
props: React.PropsWithChildren<{ props: React.PropsWithChildren<{
title: string | React.ReactNode; title: string | React.ReactNode;
description: string | React.ReactNode; description: string | React.ReactNode;
account: string; account: string;
}>, }>,
) { ) {
const cookieStore = await cookies();
const layoutStyleCookie = cookieStore.get('layout-style')?.value;
const defaultStyle = getTeamAccountSidebarConfig(props.account).style;
const displaySidebarTrigger =
(layoutStyleCookie ?? defaultStyle) === 'sidebar';
return ( return (
<PageHeader description={props.description}>{props.children}</PageHeader> <PageHeader
description={props.description}
displaySidebarTrigger={displaySidebarTrigger}
>
{props.children}
</PageHeader>
); );
} }

View File

@@ -44,7 +44,9 @@ export function TeamAccountNavigationMenu(props: {
return ( return (
<div className={'flex w-full flex-1 justify-between'}> <div className={'flex w-full flex-1 justify-between'}>
<div className={'flex items-center space-x-8'}> <div className={'flex items-center space-x-8'}>
<div>
<AppLogo /> <AppLogo />
</div>
<BorderedNavigationMenu> <BorderedNavigationMenu>
{routes.map((route) => ( {routes.map((route) => (
@@ -53,11 +55,12 @@ export function TeamAccountNavigationMenu(props: {
</BorderedNavigationMenu> </BorderedNavigationMenu>
</div> </div>
<div className={'flex items-center justify-end space-x-2.5'}> <div className={'flex items-center justify-end space-x-1'}>
<If condition={featureFlagsConfig.enableNotifications}> <If condition={featureFlagsConfig.enableNotifications}>
<TeamAccountNotifications accountId={account.id} userId={user.id} /> <TeamAccountNotifications accountId={account.id} userId={user.id} />
</If> </If>
<div>
<TeamAccountAccountsSelector <TeamAccountAccountsSelector
userId={user.id} userId={user.id}
selectedAccount={account.slug} selectedAccount={account.slug}
@@ -67,6 +70,7 @@ export function TeamAccountNavigationMenu(props: {
image: account.picture_url, image: account.picture_url,
}))} }))}
/> />
</div>
<div> <div>
<ProfileAccountDropdownContainer <ProfileAccountDropdownContainer

View File

@@ -1,4 +1,6 @@
import 'server-only'; import 'server-only';
import { redirect } from 'next/navigation';
import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseClient } from '@supabase/supabase-js';
import * as z from 'zod'; import * as z from 'zod';
@@ -106,9 +108,12 @@ class TeamBillingService {
`Creating checkout session...`, `Creating checkout session...`,
); );
let checkoutToken: string | null = null;
let url: string | null | undefined;
try { try {
// call the payment gateway to create the checkout session // call the payment gateway to create the checkout session
const { checkoutToken } = await service.createCheckoutSession({ const checkout = await service.createCheckoutSession({
accountId, accountId,
plan, plan,
returnUrl, returnUrl,
@@ -118,22 +123,37 @@ class TeamBillingService {
enableDiscountField: product.enableDiscountField, enableDiscountField: product.enableDiscountField,
}); });
// return the checkout token to the client checkoutToken = checkout.checkoutToken;
// so we can call the payment gateway to complete the checkout url = checkout.url;
return {
checkoutToken,
};
} catch (error) { } catch (error) {
const message = Error.isError(error) ? error.message : error;
logger.error( logger.error(
{ {
...ctx, ...ctx,
error, error: message,
}, },
`Error creating the checkout session`, `Error creating the checkout session`,
); );
throw new Error(`Checkout not created`, { cause: error }); throw new Error(`Checkout not created`, { cause: error });
} }
// if URL provided, we redirect to the provider's hosted page
if (url) {
logger.info(
ctx,
`Checkout session created. Redirecting to hosted page...`,
);
redirect(url);
}
// return the checkout token to the client
// so we can call the payment gateway to complete the checkout
return {
checkoutToken,
};
} }
/** /**

View File

@@ -10,7 +10,6 @@ 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';
@@ -22,8 +21,8 @@ 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 { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string; bookingId: string }>; params: Promise<{ account: string; bookingId: string }>;
@@ -125,7 +124,9 @@ 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">ID: {bookingId}</p> <p className="text-muted-foreground text-sm">
ID: {bookingId}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -143,7 +144,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-muted-foreground text-sm"> <span className="text-sm text-muted-foreground">
Zimmernummer Zimmernummer
</span> </span>
<span className="font-medium"> <span className="font-medium">
@@ -152,14 +153,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-muted-foreground text-sm"> <span className="text-sm text-muted-foreground">
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-muted-foreground text-sm">Typ</span> <span className="text-sm text-muted-foreground">Typ</span>
<span className="font-medium"> <span className="font-medium">
{String(room.room_type ?? '—')} {String(room.room_type ?? '—')}
</span> </span>
@@ -185,25 +186,29 @@ 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-muted-foreground text-sm">Name</span> <span className="text-sm text-muted-foreground">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-muted-foreground text-sm"> <span className="text-sm text-muted-foreground">
E-Mail E-Mail
</span> </span>
<span className="font-medium">{String(guest.email)}</span> <span className="font-medium">
{String(guest.email)}
</span>
</div> </div>
)} )}
{guest.phone && ( {guest.phone && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground text-sm"> <span className="text-sm text-muted-foreground">
Telefon Telefon
</span> </span>
<span className="font-medium">{String(guest.phone)}</span> <span className="font-medium">
{String(guest.phone)}
</span>
</div> </div>
)} )}
</div> </div>
@@ -226,30 +231,56 @@ 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-muted-foreground text-sm"> <span className="text-sm text-muted-foreground">
Check-in Check-in
</span> </span>
<span className="font-medium"> <span className="font-medium">
{formatDate(booking.check_in)} {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-muted-foreground text-sm"> <span className="text-sm text-muted-foreground">
Check-out Check-out
</span> </span>
<span className="font-medium"> <span className="font-medium">
{formatDate(booking.check_out)} {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-muted-foreground text-sm"> <span className="text-sm text-muted-foreground">
Erwachsene Erwachsene
</span> </span>
<span className="font-medium">{booking.adults ?? '—'}</span> <span className="font-medium">
{booking.adults ?? '—'}
</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground text-sm">Kinder</span> <span className="text-sm text-muted-foreground">
<span className="font-medium">{booking.children ?? 0}</span> Kinder
</span>
<span className="font-medium">
{booking.children ?? 0}
</span>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -263,7 +294,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-muted-foreground text-sm"> <span className="text-sm text-muted-foreground">
Gesamtpreis Gesamtpreis
</span> </span>
<span className="text-2xl font-bold"> <span className="text-2xl font-bold">
@@ -274,7 +305,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-muted-foreground text-sm"> <span className="text-sm text-muted-foreground">
Notizen Notizen
</span> </span>
<p className="mt-1 text-sm">{String(booking.notes)}</p> <p className="mt-1 text-sm">{String(booking.notes)}</p>
@@ -289,7 +320,9 @@ export default async function BookingDetailPage({ params }: PageProps) {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Aktionen</CardTitle> <CardTitle>Aktionen</CardTitle>
<CardDescription>Status der Buchung ändern</CardDescription> <CardDescription>
Status der Buchung ändern
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
@@ -317,10 +350,10 @@ export default async function BookingDetailPage({ params }: PageProps) {
)} )}
{status === 'cancelled' || status === 'checked_out' ? ( {status === 'cancelled' || status === 'checked_out' ? (
<p className="text-muted-foreground py-2 text-sm"> <p className="text-sm text-muted-foreground py-2">
Diese Buchung ist{' '} Diese Buchung ist{' '}
{status === 'cancelled' ? 'storniert' : 'abgeschlossen'} {status === 'cancelled' ? 'storniert' : 'abgeschlossen'} keine
keine weiteren Aktionen verfügbar. weiteren Aktionen verfügbar.
</p> </p>
) : null} ) : null}
</div> </div>

View File

@@ -2,14 +2,15 @@ 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 { AccountNotFound } from '~/components/account-not-found'; import { createBookingManagementApi } from '@kit/booking-management/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -42,11 +43,7 @@ function getFirstWeekday(year: number, month: number): number {
return day === 0 ? 6 : day - 1; return day === 0 ? 6 : day - 1;
} }
function isDateInRange( function isDateInRange(date: string, checkIn: string, checkOut: string): boolean {
date: string,
checkIn: string,
checkOut: string,
): boolean {
return date >= checkIn && date < checkOut; return date >= checkIn && date < checkOut;
} }
@@ -104,11 +101,7 @@ export default async function BookingCalendarPage({ params }: PageProps) {
} }
// Build calendar grid cells // Build calendar grid cells
const cells: Array<{ const cells: Array<{ day: number | null; occupied: boolean; isToday: boolean }> = [];
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++) {
@@ -165,11 +158,11 @@ export default async function BookingCalendarPage({ params }: PageProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{/* Weekday Header */} {/* Weekday Header */}
<div className="mb-1 grid grid-cols-7 gap-1"> <div className="grid grid-cols-7 gap-1 mb-1">
{WEEKDAYS.map((day) => ( {WEEKDAYS.map((day) => (
<div <div
key={day} key={day}
className="text-muted-foreground py-2 text-center text-xs font-medium" className="text-center text-xs font-medium text-muted-foreground py-2"
> >
{day} {day}
</div> </div>
@@ -187,13 +180,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-primary ring-2 ring-offset-1' : ''}`} } ${cell.isToday ? 'ring-2 ring-primary ring-offset-1' : ''}`}
> >
{cell.day !== null && ( {cell.day !== null && (
<> <>
<span>{cell.day}</span> <span>{cell.day}</span>
{cell.occupied && ( {cell.occupied && (
<span className="bg-primary absolute bottom-1 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full" /> <span className="absolute bottom-1 left-1/2 -translate-x-1/2 h-1.5 w-1.5 rounded-full bg-primary" />
)} )}
</> </>
)} )}
@@ -202,17 +195,17 @@ export default async function BookingCalendarPage({ params }: PageProps) {
</div> </div>
{/* Legend */} {/* Legend */}
<div className="text-muted-foreground mt-4 flex items-center gap-4 text-xs"> <div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="bg-primary/15 inline-block h-3 w-3 rounded-sm" /> <span className="inline-block h-3 w-3 rounded-sm bg-primary/15" />
Belegt Belegt
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="bg-muted/30 inline-block h-3 w-3 rounded-sm" /> <span className="inline-block h-3 w-3 rounded-sm bg-muted/30" />
Frei Frei
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="ring-primary inline-block h-3 w-3 rounded-sm ring-2" /> <span className="inline-block h-3 w-3 rounded-sm ring-2 ring-primary" />
Heute Heute
</div> </div>
</div> </div>
@@ -224,7 +217,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-muted-foreground text-sm"> <p className="text-sm text-muted-foreground">
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,13 +1,14 @@
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 { AccountNotFound } from '~/components/account-not-found'; import { createBookingManagementApi } from '@kit/booking-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 { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -39,7 +40,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 data-test="guests-new-btn"> <Button>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neuer Gast Neuer Gast
</Button> </Button>
@@ -61,7 +62,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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th> <th className="p-3 text-left font-medium">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>
@@ -71,13 +72,9 @@ export default async function GuestsPage({ params }: PageProps) {
</thead> </thead>
<tbody> <tbody>
{guests.map((guest: Record<string, unknown>) => ( {guests.map((guest: Record<string, unknown>) => (
<tr <tr key={String(guest.id)} className="border-b hover:bg-muted/30">
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.last_name ?? '')}, {String(guest.first_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,22 +1,15 @@
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 { 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 { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props { params: Promise<{ account: string }> }
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 const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
.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">
@@ -29,19 +22,12 @@ export default async function NewBookingPage({ params }: Props) {
const rooms = await api.listRooms(acct.id); const rooms = await api.listRooms(acct.id);
return ( return (
<CmsPageShell <CmsPageShell account={account} title="Neue Buchung" description="Buchung erstellen">
account={account}
title="Neue Buchung"
description="Buchung erstellen"
>
<CreateBookingForm <CreateBookingForm
accountId={acct.id} accountId={acct.id}
account={account} account={account}
rooms={(rooms ?? []).map((r: Record<string, unknown>) => ({ rooms={(rooms ?? []).map((r: Record<string, unknown>) => ({
id: String(r.id), id: String(r.id), roomNumber: String(r.room_number), name: String(r.name ?? ''), pricePerNight: Number(r.price_per_night ?? 0)
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 { AccountNotFound } from '~/components/account-not-found'; import { createBookingManagementApi } from '@kit/booking-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 PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -42,10 +42,7 @@ const STATUS_LABEL: Record<string, string> = {
no_show: 'Nicht erschienen', no_show: 'Nicht erschienen',
}; };
export default async function BookingsPage({ export default async function BookingsPage({ params, searchParams }: PageProps) {
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();
@@ -90,9 +87,9 @@ export default async function BookingsPage({
// 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((booking) => { bookingsData = bookingsData.filter((b) => {
const room = booking.room as Record<string, string> | null; const room = b.room as Record<string, string> | null;
const guest = booking.guest as Record<string, string> | null; const guest = b.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();
@@ -107,8 +104,7 @@ export default async function BookingsPage({
} }
const activeBookings = bookingsData.filter( const activeBookings = bookingsData.filter(
(booking) => (b) => b.status === 'confirmed' || b.status === 'checked_in',
booking.status === 'confirmed' || booking.status === 'checked_in',
); );
const totalPages = Math.ceil(total / PAGE_SIZE); const totalPages = Math.ceil(total / PAGE_SIZE);
@@ -123,7 +119,7 @@ export default async function BookingsPage({
</p> </p>
<Link href={`/home/${account}/bookings/new`}> <Link href={`/home/${account}/bookings/new`}>
<Button data-test="bookings-new-btn"> <Button>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neue Buchung Neue Buchung
</Button> </Button>
@@ -152,7 +148,7 @@ export default async function BookingsPage({
{/* 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="text-muted-foreground absolute top-1/2 left-2.5 h-4 w-4 -translate-y-1/2" /> <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
name="q" name="q"
defaultValue={searchQuery} defaultValue={searchQuery}
@@ -204,7 +200,7 @@ export default async function BookingsPage({
<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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Zimmer</th> <th className="p-3 text-left font-medium">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>
@@ -215,19 +211,13 @@ export default async function BookingsPage({
</thead> </thead>
<tbody> <tbody>
{bookingsData.map((booking) => { {bookingsData.map((booking) => {
const room = booking.room as Record< const room = booking.room as Record<string, string> | null;
string, const guest = booking.guest as Record<string, string> | null;
string
> | null;
const guest = booking.guest as Record<
string,
string
> | null;
return ( return (
<tr <tr
key={String(booking.id)} key={String(booking.id)}
className="hover:bg-muted/30 border-b" className="border-b hover:bg-muted/30"
> >
<td className="p-3"> <td className="p-3">
<Link <Link
@@ -245,10 +235,18 @@ export default async function BookingsPage({
: '—'} : '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
{formatDate(booking.check_in)} {booking.check_in
? new Date(
String(booking.check_in),
).toLocaleDateString('de-DE')
: '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
{formatDate(booking.check_out)} {booking.check_out
? new Date(
String(booking.check_out),
).toLocaleDateString('de-DE')
: '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
<Badge <Badge
@@ -276,12 +274,14 @@ export default async function BookingsPage({
{/* 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-muted-foreground text-sm"> <p className="text-sm text-muted-foreground">
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 href={`/home/${account}/bookings?page=${page - 1}`}> <Link
href={`/home/${account}/bookings?page=${page - 1}`}
>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
Zurück Zurück
</Button> </Button>
@@ -293,7 +293,9 @@ export default async function BookingsPage({
)} )}
{page < totalPages ? ( {page < totalPages ? (
<Link href={`/home/${account}/bookings?page=${page + 1}`}> <Link
href={`/home/${account}/bookings?page=${page + 1}`}
>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
Weiter Weiter
</Button> </Button>

View File

@@ -1,14 +1,15 @@
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 { AccountNotFound } from '~/components/account-not-found'; import { createBookingManagementApi } from '@kit/booking-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 { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -40,7 +41,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 data-test="rooms-new-btn"> <Button>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neues Zimmer Neues Zimmer
</Button> </Button>
@@ -62,37 +63,26 @@ 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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Zimmernr.</th> <th className="p-3 text-left font-medium">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"> <th className="p-3 text-right font-medium">Preis/Nacht</th>
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 <tr key={String(room.id)} className="border-b hover:bg-muted/30">
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"> <td className="p-3 font-medium">{String(room.name ?? '—')}</td>
{String(room.name ?? '—')}
</td>
<td className="p-3"> <td className="p-3">
<Badge variant="outline"> <Badge variant="outline">{String(room.room_type ?? '—')}</Badge>
{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,12 +1,11 @@
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 { AccountNotFound } from '~/components/account-not-found'; import { createCourseManagementApi } from '@kit/course-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';
@@ -15,10 +14,7 @@ interface PageProps {
searchParams: Promise<Record<string, string | string[] | undefined>>; searchParams: Promise<Record<string, string | string[] | undefined>>;
} }
export default async function AttendancePage({ export default async function AttendancePage({ params, searchParams }: PageProps) {
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();
@@ -30,23 +26,16 @@ export default async function AttendancePage({
api.getParticipants(courseId), api.getParticipants(courseId),
]); ]);
if (!course) return <AccountNotFound />; if (!course) return <div>Kurs nicht gefunden</div>;
const selectedSessionId = const selectedSessionId = (search.session as string) ?? (sessions.length > 0 ? String((sessions[0] as Record<string, unknown>).id) : null);
(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>) => [ attendance.map((a: Record<string, unknown>) => [String(a.participant_id), Boolean(a.present)]),
String(a.participant_id),
Boolean(a.present),
]),
); );
return ( return (
@@ -81,12 +70,9 @@ export default async function AttendancePage({
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 <Badge variant={isSelected ? 'default' : 'outline'} className="cursor-pointer px-3 py-1">
variant={isSelected ? 'default' : 'outline'}
className="cursor-pointer px-3 py-1"
>
{s.session_date {s.session_date
? formatDate(s.session_date as string) ? new Date(String(s.session_date)).toLocaleDateString('de-DE')
: String(s.id)} : String(s.id)}
</Badge> </Badge>
</a> </a>
@@ -106,38 +92,28 @@ export default async function AttendancePage({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{participants.length === 0 ? ( {participants.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm"> <p className="py-6 text-center text-sm text-muted-foreground">
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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium"> <th className="p-3 text-left font-medium">Teilnehmer</th>
Teilnehmer <th className="p-3 text-center font-medium">Anwesend</th>
</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 <tr key={String(p.id)} className="border-b hover:bg-muted/30">
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.last_name ?? '')}, {String(p.first_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={ defaultChecked={attendanceMap.get(String(p.id)) ?? false}
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,22 +1,14 @@
import Link from 'next/link'; import Link from 'next/link';
import { import { GraduationCap, Users, Calendar, Euro, User, Clock } from 'lucide-react';
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 { AccountNotFound } from '~/components/account-not-found'; import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps { interface PageProps {
@@ -24,22 +16,13 @@ interface PageProps {
} }
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
planned: 'Geplant', planned: 'Geplant', open: 'Offen', running: 'Laufend',
open: 'Offen', completed: 'Abgeschlossen', cancelled: 'Abgesagt',
running: 'Laufend',
completed: 'Abgeschlossen',
cancelled: 'Abgesagt',
}; };
const STATUS_VARIANT: Record< const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'outline' | 'destructive'> = {
string, planned: 'secondary', open: 'default', running: 'info',
'secondary' | 'default' | 'info' | 'outline' | 'destructive' completed: 'outline', cancelled: '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) {
@@ -53,84 +36,75 @@ export default async function CourseDetailPage({ params }: PageProps) {
api.getSessions(courseId), api.getSessions(courseId),
]); ]);
if (!course) return <AccountNotFound />; if (!course) return <div>Kurs nicht gefunden</div>;
const courseData = course as Record<string, unknown>; const c = course as Record<string, unknown>;
return ( return (
<CmsPageShell account={account} title={String(courseData.name)}> <CmsPageShell account={account} title={String(c.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="text-primary h-5 w-5" /> <GraduationCap className="h-5 w-5 text-primary" />
<div> <div>
<p className="text-muted-foreground text-xs">Name</p> <p className="text-xs text-muted-foreground">Name</p>
<p className="font-semibold">{String(courseData.name)}</p> <p className="font-semibold">{String(c.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="text-primary h-5 w-5" /> <Clock className="h-5 w-5 text-primary" />
<div> <div>
<p className="text-muted-foreground text-xs">Status</p> <p className="text-xs text-muted-foreground">Status</p>
<Badge <Badge variant={STATUS_VARIANT[String(c.status)] ?? 'secondary'}>
variant={ {STATUS_LABEL[String(c.status)] ?? String(c.status)}
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="text-primary h-5 w-5" /> <User className="h-5 w-5 text-primary" />
<div> <div>
<p className="text-muted-foreground text-xs">Dozent</p> <p className="text-xs text-muted-foreground">Dozent</p>
<p className="font-semibold"> <p className="font-semibold">{String(c.instructor_id ?? '—')}</p>
{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="text-primary h-5 w-5" /> <Calendar className="h-5 w-5 text-primary" />
<div> <div>
<p className="text-muted-foreground text-xs">Beginn Ende</p> <p className="text-xs text-muted-foreground">Beginn Ende</p>
<p className="font-semibold"> <p className="font-semibold">
{formatDate(courseData.start_date as string)} {c.start_date ? new Date(String(c.start_date)).toLocaleDateString('de-DE') : '—'}
{' '} {' '}
{formatDate(courseData.end_date as string)} {c.end_date ? new Date(String(c.end_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">
<Euro className="text-primary h-5 w-5" /> <Euro className="h-5 w-5 text-primary" />
<div> <div>
<p className="text-muted-foreground text-xs">Gebühr</p> <p className="text-xs text-muted-foreground">Gebühr</p>
<p className="font-semibold"> <p className="font-semibold">
{courseData.fee != null {c.fee != null ? `${Number(c.fee).toFixed(2)}` : '—'}
? `${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="text-primary h-5 w-5" /> <Users className="h-5 w-5 text-primary" />
<div> <div>
<p className="text-muted-foreground text-xs">Teilnehmer</p> <p className="text-xs text-muted-foreground">Teilnehmer</p>
<p className="font-semibold"> <p className="font-semibold">
{participants.length} / {String(courseData.capacity ?? '∞')} {participants.length} / {String(c.capacity ?? '∞')}
</p> </p>
</div> </div>
</CardContent> </CardContent>
@@ -142,16 +116,14 @@ 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"> <Button variant="outline" size="sm">Alle anzeigen</Button>
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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th> <th className="p-3 text-left font-medium">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>
@@ -160,36 +132,15 @@ export default async function CourseDetailPage({ params }: PageProps) {
</thead> </thead>
<tbody> <tbody>
{participants.length === 0 ? ( {participants.length === 0 ? (
<tr> <tr><td colSpan={4} className="p-6 text-center text-muted-foreground">Keine Teilnehmer</td></tr>
<td ) : participants.map((p: Record<string, unknown>) => (
colSpan={4} <tr key={String(p.id)} className="border-b hover:bg-muted/30">
className="text-muted-foreground p-6 text-center" <td className="p-3 font-medium">{String(p.last_name ?? '')}, {String(p.first_name ?? '')}</td>
>
Keine Teilnehmer
</td>
</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">{String(p.email ?? '—')}</td>
<td className="p-3"> <td className="p-3"><Badge variant="outline">{String(p.status ?? '—')}</Badge></td>
<Badge variant="outline"> <td className="p-3">{p.enrolled_at ? new Date(String(p.enrolled_at)).toLocaleDateString('de-DE') : '—'}</td>
{String(p.status ?? '—')}
</Badge>
</td>
<td className="p-3">
{formatDate(p.enrolled_at as string)}
</td>
</tr> </tr>
)) ))}
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -201,16 +152,14 @@ 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"> <Button variant="outline" size="sm">Anwesenheit</Button>
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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Datum</th> <th className="p-3 text-left font-medium">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>
@@ -219,35 +168,15 @@ export default async function CourseDetailPage({ params }: PageProps) {
</thead> </thead>
<tbody> <tbody>
{sessions.length === 0 ? ( {sessions.length === 0 ? (
<tr> <tr><td colSpan={4} className="p-6 text-center text-muted-foreground">Keine Termine</td></tr>
<td ) : sessions.map((s: Record<string, unknown>) => (
colSpan={4} <tr key={String(s.id)} className="border-b hover:bg-muted/30">
className="text-muted-foreground p-6 text-center" <td className="p-3">{s.session_date ? new Date(String(s.session_date)).toLocaleDateString('de-DE') : '—'}</td>
>
Keine Termine
</td>
</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.start_time ?? '—')}</td>
<td className="p-3">{String(s.end_time ?? '—')}</td> <td className="p-3">{String(s.end_time ?? '—')}</td>
<td className="p-3"> <td className="p-3">{s.cancelled ? <Badge variant="destructive">Ja</Badge> : '—'}</td>
{s.cancelled ? (
<Badge variant="destructive">Ja</Badge>
) : (
'—'
)}
</td>
</tr> </tr>
)) ))}
)}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -2,14 +2,13 @@ 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 { AccountNotFound } from '~/components/account-not-found'; import { createCourseManagementApi } from '@kit/course-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';
@@ -17,10 +16,7 @@ interface PageProps {
params: Promise<{ account: string; courseId: string }>; params: Promise<{ account: string; courseId: string }>;
} }
const STATUS_VARIANT: Record< const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'outline' | 'destructive'> = {
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
enrolled: 'default', enrolled: 'default',
waitlisted: 'secondary', waitlisted: 'secondary',
cancelled: 'destructive', cancelled: 'destructive',
@@ -44,7 +40,7 @@ export default async function ParticipantsPage({ params }: PageProps) {
api.getParticipants(courseId), api.getParticipants(courseId),
]); ]);
if (!course) return <AccountNotFound />; if (!course) return <div>Kurs nicht gefunden</div>;
return ( return (
<CmsPageShell account={account} title="Teilnehmer"> <CmsPageShell account={account} title="Teilnehmer">
@@ -53,11 +49,10 @@ 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)} {' '} {String((course as Record<string, unknown>).name)} {participants.length} Teilnehmer
{participants.length} Teilnehmer
</p> </p>
</div> </div>
<Button data-test="participants-add-btn"> <Button>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Teilnehmer anmelden Teilnehmer anmelden
</Button> </Button>
@@ -79,39 +74,31 @@ 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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th> <th className="p-3 text-left font-medium">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"> <th className="p-3 text-left font-medium">Anmeldedatum</th>
Anmeldedatum
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{participants.map((p: Record<string, unknown>) => ( {participants.map((p: Record<string, unknown>) => (
<tr <tr key={String(p.id)} className="border-b hover:bg-muted/30">
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.last_name ?? '')}, {String(p.first_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 <Badge variant={STATUS_VARIANT[String(p.status)] ?? 'secondary'}>
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">
{formatDate(p.enrolled_at as string)} {p.enrolled_at
? 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 { AccountNotFound } from '~/components/account-not-found'; import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -67,14 +67,10 @@ 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 courseItem = course as Record<string, unknown>; const c = course as Record<string, unknown>;
if (courseItem.status === 'cancelled') continue; if (c.status === 'cancelled') continue;
const startDate = courseItem.start_date const startDate = c.start_date ? new Date(String(c.start_date)) : null;
? new Date(String(courseItem.start_date)) const endDate = c.end_date ? new Date(String(c.end_date)) : null;
: null;
const endDate = courseItem.end_date
? new Date(String(courseItem.end_date))
: null;
if (!startDate) continue; if (!startDate) continue;
@@ -90,11 +86,7 @@ export default async function CourseCalendarPage({ params }: PageProps) {
} }
// Build calendar grid // Build calendar grid
const cells: Array<{ const cells: Array<{ day: number | null; hasCourse: boolean; isToday: boolean }> = [];
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 });
@@ -104,10 +96,7 @@ export default async function CourseCalendarPage({ params }: PageProps) {
cells.push({ cells.push({
day: d, day: d,
hasCourse: courseDates.has(d), hasCourse: courseDates.has(d),
isToday: isToday: d === now.getDate() && month === now.getMonth() && year === now.getFullYear(),
d === now.getDate() &&
month === now.getMonth() &&
year === now.getFullYear(),
}); });
} }
@@ -116,8 +105,8 @@ export default async function CourseCalendarPage({ params }: PageProps) {
} }
const activeCourses = courses.data.filter( const activeCourses = courses.data.filter(
(courseItem: Record<string, unknown>) => (c: Record<string, unknown>) =>
courseItem.status === 'open' || courseItem.status === 'running', c.status === 'open' || c.status === 'running',
); );
return ( return (
@@ -131,7 +120,9 @@ 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">Kurstermine im Überblick</p> <p className="text-muted-foreground">
Kurstermine im Überblick
</p>
</div> </div>
</div> </div>
@@ -152,11 +143,11 @@ export default async function CourseCalendarPage({ params }: PageProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{/* Weekday Header */} {/* Weekday Header */}
<div className="mb-1 grid grid-cols-7 gap-1"> <div className="grid grid-cols-7 gap-1 mb-1">
{WEEKDAYS.map((day) => ( {WEEKDAYS.map((day) => (
<div <div
key={day} key={day}
className="text-muted-foreground py-2 text-center text-xs font-medium" className="text-center text-xs font-medium text-muted-foreground py-2"
> >
{day} {day}
</div> </div>
@@ -172,15 +163,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 font-semibold text-emerald-700 dark:text-emerald-400' ? 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 font-semibold'
: 'bg-muted/30 hover:bg-muted/50' : 'bg-muted/30 hover:bg-muted/50'
} ${cell.isToday ? 'ring-primary ring-2 ring-offset-1' : ''}`} } ${cell.isToday ? 'ring-2 ring-primary 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 h-1.5 w-1.5 -translate-x-1/2 rounded-full bg-emerald-500" /> <span className="absolute bottom-1 left-1/2 -translate-x-1/2 h-1.5 w-1.5 rounded-full bg-emerald-500" />
)} )}
</> </>
)} )}
@@ -189,17 +180,17 @@ export default async function CourseCalendarPage({ params }: PageProps) {
</div> </div>
{/* Legend */} {/* Legend */}
<div className="text-muted-foreground mt-4 flex items-center gap-4 text-xs"> <div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
<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="bg-muted/30 inline-block h-3 w-3 rounded-sm" /> <span className="inline-block h-3 w-3 rounded-sm bg-muted/30" />
Frei Frei
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="ring-primary inline-block h-3 w-3 rounded-sm ring-2" /> <span className="inline-block h-3 w-3 rounded-sm ring-2 ring-primary" />
Heute Heute
</div> </div>
</div> </div>
@@ -213,7 +204,7 @@ export default async function CourseCalendarPage({ params }: PageProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{activeCourses.length === 0 ? ( {activeCourses.length === 0 ? (
<p className="text-muted-foreground text-sm"> <p className="text-sm text-muted-foreground">
Keine aktiven Kurse in diesem Monat. Keine aktiven Kurse in diesem Monat.
</p> </p>
) : ( ) : (
@@ -230,19 +221,18 @@ export default async function CourseCalendarPage({ params }: PageProps) {
> >
{String(course.name)} {String(course.name)}
</Link> </Link>
<p className="text-muted-foreground text-xs"> <p className="text-xs text-muted-foreground">
{formatDate(course.start_date as string)} {' '} {course.start_date
{formatDate(course.end_date as string)} ? new Date(String(course.start_date)).toLocaleDateString('de-DE')
: '—'}{' '}
{' '}
{course.end_date
? new Date(String(course.end_date)).toLocaleDateString('de-DE')
: '—'}
</p> </p>
</div> </div>
<Badge <Badge variant={String(course.status) === 'running' ? 'info' : 'default'}>
variant={ {String(course.status) === 'running' ? 'Laufend' : 'Offen'}
String(course.status) === 'running' ? 'info' : 'default'
}
>
{String(course.status) === 'running'
? 'Laufend'
: 'Offen'}
</Badge> </Badge>
</div> </div>
))} ))}

View File

@@ -1,13 +1,14 @@
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 { AccountNotFound } from '~/components/account-not-found'; import { createCourseManagementApi } from '@kit/course-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 { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -33,7 +34,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 data-test="categories-new-btn"> <Button>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neue Kategorie Neue Kategorie
</Button> </Button>
@@ -55,24 +56,17 @@ 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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th> <th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium"> <th className="p-3 text-left font-medium">Beschreibung</th>
Beschreibung <th className="p-3 text-left font-medium">Übergeordnet</th>
</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 <tr key={String(cat.id)} className="border-b hover:bg-muted/30">
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="text-muted-foreground p-3"> <td className="p-3 text-muted-foreground">
{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,13 +1,14 @@
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 { AccountNotFound } from '~/components/account-not-found'; import { createCourseManagementApi } from '@kit/course-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 { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -33,7 +34,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 data-test="instructors-new-btn"> <Button>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neuer Dozent Neuer Dozent
</Button> </Button>
@@ -55,33 +56,23 @@ 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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th> <th className="p-3 text-left font-medium">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"> <th className="p-3 text-left font-medium">Qualifikation</th>
Qualifikation <th className="p-3 text-right font-medium">Stundensatz</th>
</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 <tr key={String(inst.id)} className="border-b hover:bg-muted/30">
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.last_name ?? '')}, {String(inst.first_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"> <td className="p-3">{String(inst.qualification ?? '—')}</td>
{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,13 +1,14 @@
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 { AccountNotFound } from '~/components/account-not-found'; import { createCourseManagementApi } from '@kit/course-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 { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -32,10 +33,8 @@ 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"> <p className="text-muted-foreground">Kurs- und Veranstaltungsorte verwalten</p>
Kurs- und Veranstaltungsorte verwalten <Button>
</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>
@@ -57,7 +56,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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th> <th className="p-3 text-left font-medium">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>
@@ -66,10 +65,7 @@ export default async function LocationsPage({ params }: PageProps) {
</thead> </thead>
<tbody> <tbody>
{locations.map((loc: Record<string, unknown>) => ( {locations.map((loc: Record<string, unknown>) => (
<tr <tr key={String(loc.id)} className="border-b hover:bg-muted/30">
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]
@@ -78,9 +74,7 @@ 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"> <td className="p-3 text-right">{String(loc.capacity ?? '—')}</td>
{String(loc.capacity ?? '—')}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -1,29 +1,18 @@
import { CreateCourseForm } from '@kit/course-management/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateCourseForm } from '@kit/course-management/components';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props { params: Promise<{ account: string }> }
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 const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell <CmsPageShell account={account} title="Neuer Kurs" description="Kurs anlegen">
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,30 +1,19 @@
import Link from 'next/link'; import Link from 'next/link';
import { import { ChevronLeft, ChevronRight, GraduationCap, Plus, Users, Calendar, Euro } from 'lucide-react';
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 { AccountNotFound } from '~/components/account-not-found'; import { createCourseManagementApi } from '@kit/course-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 { import { AccountNotFound } from '~/components/account-not-found';
COURSE_STATUS_VARIANT, import { COURSE_STATUS_VARIANT, COURSE_STATUS_LABEL } from '~/lib/status-badges';
COURSE_STATUS_LABEL,
} from '~/lib/status-badges';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -61,10 +50,12 @@ 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">Kursangebot verwalten</p> <p className="text-muted-foreground">
Kursangebot verwalten
</p>
<Link href={`/home/${account}/courses/new`}> <Link href={`/home/${account}/courses/new`}>
<Button data-test="courses-new-btn"> <Button>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neuer Kurs Neuer Kurs
</Button> </Button>
@@ -113,7 +104,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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Kursnr.</th> <th className="p-3 text-left font-medium">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>
@@ -125,10 +116,7 @@ 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 <tr key={String(course.id)} className="border-b hover:bg-muted/30">
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>
@@ -141,20 +129,20 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
</Link> </Link>
</td> </td>
<td className="p-3"> <td className="p-3">
{formatDate(course.start_date as string)} {course.start_date
? new Date(String(course.start_date)).toLocaleDateString('de-DE')
: '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
{formatDate(course.end_date as string)} {course.end_date
? new Date(String(course.end_date)).toLocaleDateString('de-DE')
: '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
<Badge <Badge
variant={ variant={COURSE_STATUS_VARIANT[String(course.status)] ?? 'secondary'}
COURSE_STATUS_VARIANT[String(course.status)] ??
'secondary'
}
> >
{COURSE_STATUS_LABEL[String(course.status)] ?? {COURSE_STATUS_LABEL[String(course.status)] ?? String(course.status)}
String(course.status)}
</Badge> </Badge>
</td> </td>
<td className="p-3 text-right"> <td className="p-3 text-right">
@@ -176,7 +164,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-muted-foreground text-sm"> <p className="text-sm text-muted-foreground">
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,19 +1,14 @@
import { import { GraduationCap, Users, Calendar, TrendingUp, BarChart3 } from 'lucide-react';
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 { AccountNotFound } from '~/components/account-not-found'; import { createCourseManagementApi } from '@kit/course-management/api';
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 }>;
@@ -23,11 +18,7 @@ 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 const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client); const api = createCourseManagementApi(client);
@@ -43,26 +34,10 @@ 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 <StatsCard title="Kurse gesamt" value={stats.totalCourses} icon={<GraduationCap className="h-5 w-5" />} />
title="Kurse gesamt" <StatsCard title="Aktive Kurse" value={stats.openCourses} icon={<Calendar className="h-5 w-5" />} />
value={stats.totalCourses} <StatsCard title="Teilnehmer" value={stats.totalParticipants} icon={<Users className="h-5 w-5" />} />
icon={<GraduationCap className="h-5 w-5" />} <StatsCard title="Abgeschlossen" value={stats.completedCourses} icon={<TrendingUp 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,13 +68,12 @@ 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:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none" className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<option value="member-card">Mitgliedsausweis</option> <option value="member-card">Mitgliedsausweis</option>
<option value="invoice">Rechnung</option> <option value="invoice">Rechnung</option>
@@ -93,8 +92,7 @@ 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 befindet sich noch in Entwicklung und wird in Kürze verfügbar sein.
sein.
</p> </p>
</div> </div>
</div> </div>
@@ -120,7 +118,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:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none 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:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50"
> >
<option value="A4">A4</option> <option value="A4">A4</option>
<option value="A5">A5</option> <option value="A5">A5</option>
@@ -133,7 +131,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:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none 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:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50"
> >
<option value="portrait">Hochformat</option> <option value="portrait">Hochformat</option>
<option value="landscape">Querformat</option> <option value="landscape">Querformat</option>
@@ -142,7 +140,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
</div> </div>
{/* Hint */} {/* Hint */}
<div className="text-muted-foreground bg-muted/50 rounded-md p-4 text-sm"> <div className="text-muted-foreground rounded-md bg-muted/50 p-4 text-sm">
<p> <p>
<strong>Hinweis:</strong>{' '} <strong>Hinweis:</strong>{' '}
{selectedType === 'member-card' {selectedType === 'member-card'
@@ -191,11 +189,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
{/* Submit button */} {/* Submit button */}
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button type="submit" disabled={isPending || isComingSoon}>
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" />
@@ -217,7 +211,11 @@ 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(base64Data: string, mimeType: string, fileName: string) { function downloadFile(
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,10 +2,8 @@
import React from 'react'; import React from 'react';
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'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createDocumentGeneratorApi } from '@kit/document-generator/api';
export type GenerateDocumentInput = { export type GenerateDocumentInput = {
accountSlug: string; accountSlug: string;
@@ -57,11 +55,7 @@ export async function generateDocumentAction(
return { success: false, error: 'Unbekannter Dokumenttyp.' }; return { success: false, error: 'Unbekannter Dokumenttyp.' };
} }
} catch (err) { } catch (err) {
const logger = await getLogger(); console.error('Document generation error:', err);
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.',
@@ -79,7 +73,8 @@ const LABELS: Record<string, string> = {
}; };
function fmtDate(d: string | null): string { function fmtDate(d: string | null): string {
return formatDate(d); if (!d) return '';
try { return new Date(d).toLocaleDateString('de-DE'); } catch { return d; }
} }
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@@ -93,28 +88,16 @@ async function generateMemberCards(
): Promise<GenerateDocumentResult> { ): Promise<GenerateDocumentResult> {
const { data: members, error } = await client const { data: members, error } = await client
.from('members') .from('members')
.select( .select('id, member_number, first_name, last_name, entry_date, status, date_of_birth, street, house_number, postal_code, city, email')
'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) if (!members?.length) return { success: false, error: 'Keine aktiven Mitglieder.' };
return { success: false, error: 'Keine aktiven Mitglieder.' };
const { const { Document, Page, View, Text, StyleSheet, renderToBuffer, Svg, Rect, Circle } =
Document, await import('@react-pdf/renderer');
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';
@@ -124,13 +107,7 @@ async function generateMemberCards(
const LIGHT_GRAY = '#f1f5f9'; const LIGHT_GRAY = '#f1f5f9';
const s = StyleSheet.create({ const s = StyleSheet.create({
page: { page: { padding: 24, flexDirection: 'row', flexWrap: 'wrap', gap: 16, fontFamily: 'Helvetica' },
padding: 24,
flexDirection: 'row',
flexWrap: 'wrap',
gap: 16,
fontFamily: 'Helvetica',
},
// ── Card shell ── // ── Card shell ──
card: { card: {
@@ -161,22 +138,10 @@ async function generateMemberCards(
paddingHorizontal: 6, paddingHorizontal: 6,
paddingVertical: 2, paddingVertical: 2,
}, },
badgeText: { badgeText: { fontSize: 6, color: PRIMARY, fontFamily: 'Helvetica-Bold', textTransform: 'uppercase' as const, letterSpacing: 0.8 },
fontSize: 6,
color: PRIMARY,
fontFamily: 'Helvetica-Bold',
textTransform: 'uppercase' as const,
letterSpacing: 0.8,
},
// ── Main content ── // ── Main content ──
body: { body: { flexDirection: 'row', paddingHorizontal: 14, paddingTop: 8, gap: 12, flex: 1 },
flexDirection: 'row',
paddingHorizontal: 14,
paddingTop: 8,
gap: 12,
flex: 1,
},
// Photo column // Photo column
photoCol: { width: 64, alignItems: 'center' }, photoCol: { width: 64, alignItems: 'center' },
@@ -200,22 +165,11 @@ async function generateMemberCards(
// Info column // Info column
infoCol: { flex: 1, justifyContent: 'center' }, infoCol: { flex: 1, justifyContent: 'center' },
memberName: { memberName: { fontSize: 14, fontFamily: 'Helvetica-Bold', color: DARK, marginBottom: 6 },
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: { fieldLabel: { fontSize: 6, color: GRAY, textTransform: 'uppercase' as const, letterSpacing: 0.6, marginBottom: 1 },
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 ──
@@ -230,16 +184,10 @@ async function generateMemberCards(
}, },
footerLeft: { fontSize: 6, color: GRAY }, footerLeft: { fontSize: 6, color: GRAY },
footerRight: { fontSize: 6, color: GRAY }, footerRight: { fontSize: 6, color: GRAY },
validDot: { validDot: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: '#22c55e', marginRight: 3 },
width: 5,
height: 5,
borderRadius: 2.5,
backgroundColor: '#22c55e',
marginRight: 3,
},
}); });
const today = formatDate(new Date()); const today = new Date().toLocaleDateString('de-DE');
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const cardsPerPage = 4; const cardsPerPage = 4;
const pages: React.ReactElement[] = []; const pages: React.ReactElement[] = [];
@@ -250,122 +198,52 @@ 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 },
key: `p${i}`, ...batch.map((m) =>
size: React.createElement(View, { key: m.id, style: s.card },
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( React.createElement(View, { style: s.header },
View,
{ style: s.header },
React.createElement(Text, { style: s.clubName }, accountName), React.createElement(Text, { style: s.clubName }, accountName),
React.createElement( React.createElement(View, { style: s.badge },
View, React.createElement(Text, { style: s.badgeText }, 'Mitgliedsausweis'),
{ style: s.badge },
React.createElement(
Text,
{ style: s.badgeText },
'Mitgliedsausweis',
),
), ),
), ),
// Body: photo + info // Body: photo + info
React.createElement( React.createElement(View, { style: s.body },
View,
{ style: s.body },
// Photo column // Photo column
React.createElement( React.createElement(View, { style: s.photoCol },
View, React.createElement(View, { style: s.photoFrame },
{ style: s.photoCol },
React.createElement(
View,
{ style: s.photoFrame },
React.createElement(Text, { style: s.photoIcon }, '👤'), React.createElement(Text, { style: s.photoIcon }, '👤'),
), ),
React.createElement( React.createElement(Text, { style: s.memberNumber }, `Nr. ${m.member_number ?? ''}`),
Text,
{ style: s.memberNumber },
`Nr. ${memberItem.member_number ?? ''}`,
),
), ),
// Info column // Info column
React.createElement( React.createElement(View, { style: s.infoCol },
View, React.createElement(Text, { style: s.memberName }, `${m.first_name} ${m.last_name}`),
{ style: s.infoCol }, React.createElement(View, { style: s.fieldGroup },
React.createElement(
Text,
{ style: s.memberName },
`${memberItem.first_name} ${memberItem.last_name}`,
),
React.createElement(
View,
{ style: s.fieldGroup },
// Entry date // Entry date
React.createElement( React.createElement(View, { style: s.field },
View, React.createElement(Text, { style: s.fieldLabel }, 'Mitglied seit'),
{ style: s.field }, React.createElement(Text, { style: s.fieldValue }, fmtDate(m.entry_date)),
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( React.createElement(View, { style: s.field },
View, React.createElement(Text, { style: s.fieldLabel }, 'Geb.-Datum'),
{ style: s.field }, React.createElement(Text, { style: s.fieldValue }, fmtDate(m.date_of_birth)),
React.createElement(
Text,
{ style: s.fieldLabel },
'Geb.-Datum',
),
React.createElement(
Text,
{ style: s.fieldValue },
fmtDate(memberItem.date_of_birth),
),
), ),
// Address // Address
React.createElement( React.createElement(View, { style: { ...s.field, width: '100%' } },
View, React.createElement(Text, { style: s.fieldLabel }, 'Adresse'),
{ style: { ...s.field, width: '100%' } }, React.createElement(Text, { style: s.fieldValue },
React.createElement( [m.street, m.house_number].filter(Boolean).join(' ') || '',
Text,
{ style: s.fieldLabel },
'Adresse',
), ),
React.createElement( React.createElement(Text, { style: { ...s.fieldValue, marginTop: 1 } },
Text, [m.postal_code, m.city].filter(Boolean).join(' ') || '',
{ 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(' ') || '',
), ),
), ),
), ),
@@ -373,24 +251,12 @@ async function generateMemberCards(
), ),
// Footer // Footer
React.createElement( React.createElement(View, { style: s.footer },
View, React.createElement(View, { style: { flexDirection: 'row', alignItems: 'center' } },
{ 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( React.createElement(Text, { style: s.footerLeft }, `Gültig ${year}/${year + 1}`),
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}`),
), ),
), ),
), ),
@@ -419,32 +285,19 @@ async function generateLabels(
): Promise<GenerateDocumentResult> { ): Promise<GenerateDocumentResult> {
const { data: members, error } = await client const { data: members, error } = await client
.from('members') .from('members')
.select( .select('first_name, last_name, street, house_number, postal_code, city, salutation, title')
'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) if (!members?.length) return { success: false, error: 'Keine aktiven Mitglieder.' };
return { success: false, error: 'Keine aktiven Mitglieder.' };
const api = createDocumentGeneratorApi(); const api = createDocumentGeneratorApi();
const records = members.map((record) => ({ const records = members.map((m) => ({
line1: [ line1: [m.salutation, m.title, m.first_name, m.last_name].filter(Boolean).join(' '),
record.salutation, line2: [m.street, m.house_number].filter(Boolean).join(' ') || undefined,
record.title, line3: [m.postal_code, m.city].filter(Boolean).join(' ') || undefined,
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 });
@@ -467,9 +320,7 @@ async function generateMemberReport(
): Promise<GenerateDocumentResult> { ): Promise<GenerateDocumentResult> {
const { data: members, error } = await client const { data: members, error } = await client
.from('members') .from('members')
.select( .select('member_number, last_name, first_name, email, postal_code, city, status, entry_date')
'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');
@@ -495,42 +346,27 @@ 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 = { hdr.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E40AF' } };
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> = { const SL: Record<string, string> = { active: 'Aktiv', inactive: 'Inaktiv', pending: 'Ausstehend', resigned: 'Ausgetreten', excluded: 'Ausgeschlossen' };
active: 'Aktiv',
inactive: 'Inaktiv',
pending: 'Ausstehend',
resigned: 'Ausgetreten',
excluded: 'Ausgeschlossen',
};
for (const member of members) { for (const m of members) {
ws.addRow({ ws.addRow({
nr: member.member_number ?? '', nr: m.member_number ?? '',
name: member.last_name, name: m.last_name,
vorname: member.first_name, vorname: m.first_name,
email: member.email ?? '', email: m.email ?? '',
plz: member.postal_code ?? '', plz: m.postal_code ?? '',
ort: member.city ?? '', ort: m.city ?? '',
status: SL[member.status] ?? member.status, status: SL[m.status] ?? m.status,
eintritt: member.entry_date ? formatDate(member.entry_date) : '', eintritt: m.entry_date ? new Date(m.entry_date).toLocaleDateString('de-DE') : '',
}); });
} }
ws.eachRow((row, n) => { ws.eachRow((row, n) => {
if (n > 1 && n % 2 === 0) if (n > 1 && n % 2 === 0) row.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF1F5F9' } };
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' } } };
}); });
@@ -543,8 +379,7 @@ 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: mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'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 { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -40,28 +40,32 @@ const DOCUMENT_TYPES = [
{ {
id: 'labels', id: 'labels',
title: 'Etiketten', title: 'Etiketten',
description: 'Adressetiketten für Serienbriefe im Avery-Format drucken.', description:
'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: 'Statistische Auswertungen und Berichte als PDF oder Excel.', description:
'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: 'Serienbriefe mit personalisierten Platzhaltern erstellen.', description:
'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: 'Teilnahmebescheinigungen und Zertifikate mit Unterschrift.', description:
'Teilnahmebescheinigungen und Zertifikate mit Unterschrift.',
icon: Award, icon: Award,
color: 'text-amber-600 bg-amber-50', color: 'text-amber-600 bg-amber-50',
}, },
@@ -80,11 +84,7 @@ export default async function DocumentsPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell <CmsPageShell account={account} title="Dokumente" description="Dokumente erstellen und verwalten">
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-muted-foreground text-sm"> <p className="text-sm text-muted-foreground">
{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 data-test="document-templates-new-btn"> <Button>
<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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th> <th className="p-3 text-left font-medium">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="hover:bg-muted/30 border-b" className="border-b hover:bg-muted/30"
> >
<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="text-muted-foreground p-3"> <td className="p-3 text-muted-foreground">
{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,10 +32,7 @@ const STATUS_LABEL: Record<string, string> = {
completed: 'Abgeschlossen', completed: 'Abgeschlossen',
}; };
const STATUS_VARIANT: Record< const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'outline' | 'destructive'> = {
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
draft: 'secondary', draft: 'secondary',
published: 'default', published: 'default',
registration_open: 'info', registration_open: 'info',
@@ -56,21 +53,17 @@ export default async function EventDetailPage({ params }: PageProps) {
if (!event) return <div>Veranstaltung nicht gefunden</div>; if (!event) return <div>Veranstaltung nicht gefunden</div>;
const eventData = event as Record<string, unknown>; const e = event as Record<string, unknown>;
return ( return (
<CmsPageShell account={account} title={String(eventData.name)}> <CmsPageShell account={account} title={String(e.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(eventData.name)}</h1> <h1 className="text-2xl font-bold">{String(e.name)}</h1>
<Badge <Badge variant={STATUS_VARIANT[String(e.status)] ?? 'secondary'} className="mt-1">
variant={STATUS_VARIANT[String(eventData.status)] ?? 'secondary'} {STATUS_LABEL[String(e.status)] ?? String(e.status)}
className="mt-1"
>
{STATUS_LABEL[String(eventData.status)] ??
String(eventData.status)}
</Badge> </Badge>
</div> </div>
<Button> <Button>
@@ -83,45 +76,44 @@ 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="text-primary h-5 w-5" /> <CalendarDays className="h-5 w-5 text-primary" />
<div> <div>
<p className="text-muted-foreground text-xs">Datum</p> <p className="text-xs text-muted-foreground">Datum</p>
<p className="font-semibold"> <p className="font-semibold">
{formatDate(eventData.event_date as string)} {e.event_date
? 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="text-primary h-5 w-5" /> <Clock className="h-5 w-5 text-primary" />
<div> <div>
<p className="text-muted-foreground text-xs">Uhrzeit</p> <p className="text-xs text-muted-foreground">Uhrzeit</p>
<p className="font-semibold"> <p className="font-semibold">
{String(eventData.start_time ?? '—')} {' '} {String(e.start_time ?? '—')} {String(e.end_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="text-primary h-5 w-5" /> <MapPin className="h-5 w-5 text-primary" />
<div> <div>
<p className="text-muted-foreground text-xs">Ort</p> <p className="text-xs text-muted-foreground">Ort</p>
<p className="font-semibold"> <p className="font-semibold">{String(e.location ?? '—')}</p>
{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="text-primary h-5 w-5" /> <Users className="h-5 w-5 text-primary" />
<div> <div>
<p className="text-muted-foreground text-xs">Anmeldungen</p> <p className="text-xs text-muted-foreground">Anmeldungen</p>
<p className="font-semibold"> <p className="font-semibold">
{registrations.length} / {String(eventData.capacity ?? '∞')} {registrations.length} / {String(e.capacity ?? '∞')}
</p> </p>
</div> </div>
</CardContent> </CardContent>
@@ -129,14 +121,14 @@ export default async function EventDetailPage({ params }: PageProps) {
</div> </div>
{/* Description */} {/* Description */}
{eventData.description ? ( {e.description ? (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Beschreibung</CardTitle> <CardTitle>Beschreibung</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-muted-foreground text-sm whitespace-pre-wrap"> <p className="text-sm text-muted-foreground whitespace-pre-wrap">
{String(eventData.description)} {String(e.description)}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -149,14 +141,14 @@ export default async function EventDetailPage({ params }: PageProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{registrations.length === 0 ? ( {registrations.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm"> <p className="py-6 text-center text-sm text-muted-foreground">
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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th> <th className="p-3 text-left font-medium">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>
@@ -165,20 +157,16 @@ export default async function EventDetailPage({ params }: PageProps) {
</thead> </thead>
<tbody> <tbody>
{registrations.map((reg: Record<string, unknown>) => ( {registrations.map((reg: Record<string, unknown>) => (
<tr <tr key={String(reg.id)} className="border-b hover:bg-muted/30">
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.last_name ?? '')}, {String(reg.first_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">
{String(reg.parent_name ?? '—')} {reg.created_at
</td> ? new Date(String(reg.created_at)).toLocaleDateString('de-DE')
<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 { createEventManagementApi } from '@kit/event-management/api'; import { getTranslations } from 'next-intl/server';
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 { AccountNotFound } from '~/components/account-not-found'; 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 { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -37,9 +37,7 @@ 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"> <p className="text-muted-foreground">{t('holidayPassesDescription')}</p>
{t('holidayPassesDescription')}
</p>
</div> </div>
<Button> <Button>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
@@ -57,34 +55,23 @@ export default async function HolidayPassesPage({ params }: PageProps) {
) : ( ) : (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>{t('allHolidayPasses')} ({passes.length})</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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<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"> <th className="p-3 text-right font-medium">{t('price')}</th>
{t('price')} <th className="p-3 text-left font-medium">{t('validFrom')}</th>
</th> <th className="p-3 text-left font-medium">{t('validUntil')}</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 <tr key={String(pass.id)} className="border-b hover:bg-muted/30">
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">
@@ -93,10 +80,14 @@ export default async function HolidayPassesPage({ params }: PageProps) {
: '—'} : '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
{formatDate(pass.valid_from as string)} {pass.valid_from
? new Date(String(pass.valid_from)).toLocaleDateString('de-DE')
: '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
{formatDate(pass.valid_until as string)} {pass.valid_until
? new Date(String(pass.valid_until)).toLocaleDateString('de-DE')
: '—'}
</td> </td>
</tr> </tr>
))} ))}

View File

@@ -1,32 +1,20 @@
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { CreateEventForm } from '@kit/event-management/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateEventForm } from '@kit/event-management/components';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props { params: Promise<{ account: string }> }
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 const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell <CmsPageShell account={account} title={t('newEvent')} description={t('newEventDescription')}>
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,26 +1,19 @@
import Link from 'next/link'; import Link from 'next/link';
import { import { CalendarDays, ChevronLeft, ChevronRight, MapPin, Plus, Users } from 'lucide-react';
CalendarDays,
ChevronLeft,
ChevronRight,
MapPin,
Plus,
Users,
} from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createEventManagementApi } from '@kit/event-management/api'; import { getTranslations } from 'next-intl/server';
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 { AccountNotFound } from '~/components/account-not-found'; 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';
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 {
@@ -47,21 +40,19 @@ 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((eventItem: Record<string, unknown>) => const eventIds = events.data.map((e: Record<string, unknown>) => String(e.id));
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((eventItem: Record<string, unknown>) => eventItem.location) .map((e: Record<string, unknown>) => e.location)
.filter(Boolean), .filter(Boolean),
).size; ).size;
const totalCapacity = events.data.reduce( const totalCapacity = events.data.reduce(
(sum: number, eventItem: Record<string, unknown>) => (sum: number, e: Record<string, unknown>) =>
sum + (Number(eventItem.capacity) || 0), sum + (Number(e.capacity) || 0),
0, 0,
); );
@@ -72,11 +63,13 @@ 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">{t('description')}</p> <p className="text-muted-foreground">
{t('description')}
</p>
</div> </div>
<Link href={`/home/${account}/events/new`}> <Link href={`/home/${account}/events/new`}>
<Button data-test="events-new-btn"> <Button>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
{t('newEvent')} {t('newEvent')}
</Button> </Button>
@@ -114,31 +107,19 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
) : ( ) : (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>{t('allEvents')} ({events.total})</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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<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"> <th className="p-3 text-left font-medium">{t('eventDate')}</th>
{t('eventDate')} <th className="p-3 text-left font-medium">{t('eventLocation')}</th>
</th> <th className="p-3 text-right font-medium">{t('capacity')}</th>
<th className="p-3 text-left font-medium"> <th className="p-3 text-left font-medium">{t('status')}</th>
{t('eventLocation')} <th className="p-3 text-right font-medium">{t('registrations')}</th>
</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>
@@ -149,7 +130,7 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
return ( return (
<tr <tr
key={eventId} key={eventId}
className="hover:bg-muted/30 border-b" className="border-b hover:bg-muted/30"
> >
<td className="p-3 font-medium"> <td className="p-3 font-medium">
<Link <Link
@@ -160,7 +141,9 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
</Link> </Link>
</td> </td>
<td className="p-3"> <td className="p-3">
{formatDate(event.event_date as string)} {event.event_date
? 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 ?? '—')}
@@ -173,12 +156,10 @@ 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)] ?? EVENT_STATUS_VARIANT[String(event.status)] ?? 'secondary'
'secondary'
} }
> >
{EVENT_STATUS_LABEL[String(event.status)] ?? {EVENT_STATUS_LABEL[String(event.status)] ?? 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">
@@ -194,17 +175,12 @@ 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-muted-foreground text-sm"> <span className="text-sm text-muted-foreground">
{t('paginationPage', { {t('paginationPage', { page: events.page, totalPages: events.totalPages })}
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 <Link href={`/home/${account}/events?page=${events.page - 1}`}>
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')}
@@ -212,9 +188,7 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
</Link> </Link>
)} )}
{events.page < events.totalPages && ( {events.page < events.totalPages && (
<Link <Link href={`/home/${account}/events?page=${events.page + 1}`}>
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 { createEventManagementApi } from '@kit/event-management/api'; import { getTranslations } from 'next-intl/server';
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 { AccountNotFound } from '~/components/account-not-found'; 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';
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,7 +64,9 @@ 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">{t('registrationsOverview')}</p> <p className="text-muted-foreground">
{t('registrationsOverview')}
</p>
</div> </div>
{/* Stats */} {/* Stats */}
@@ -106,25 +108,17 @@ 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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<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"> <th className="p-3 text-left font-medium">{t('eventDate')}</th>
{t('eventDate')} <th className="p-3 text-left font-medium">{t('status')}</th>
</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('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"> <th className="p-3 text-right font-medium">{t('utilization')}</th>
{t('utilization')}
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -139,7 +133,7 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
return ( return (
<tr <tr
key={event.id} key={event.id}
className="hover:bg-muted/30 border-b" className="border-b hover:bg-muted/30"
> >
<td className="p-3 font-medium"> <td className="p-3 font-medium">
<Link <Link
@@ -149,12 +143,17 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
{event.name} {event.name}
</Link> </Link>
</td> </td>
<td className="p-3">{formatDate(event.eventDate)}</td> <td className="p-3">
{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] ?? EVENT_STATUS_VARIANT[event.status] ?? 'secondary'
'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 { AccountNotFound } from '~/components/account-not-found'; import { createFinanceApi } from '@kit/finance/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
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 <AccountNotFound />; if (!invoice) return <div>Rechnung nicht gefunden</div>;
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-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Empfänger Empfänger
</dt> </dt>
<dd className="mt-1 text-sm font-semibold"> <dd className="mt-1 text-sm font-semibold">
@@ -95,23 +95,31 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Rechnungsdatum Rechnungsdatum
</dt> </dt>
<dd className="mt-1 text-sm font-semibold"> <dd className="mt-1 text-sm font-semibold">
{formatDate(invoice.issue_date)} {invoice.issue_date
? new Date(
String(invoice.issue_date),
).toLocaleDateString('de-DE')
: '—'}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Fälligkeitsdatum Fälligkeitsdatum
</dt> </dt>
<dd className="mt-1 text-sm font-semibold"> <dd className="mt-1 text-sm font-semibold">
{formatDate(invoice.due_date)} {invoice.due_date
? new Date(String(invoice.due_date)).toLocaleDateString(
'de-DE',
)
: '—'}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Gesamtbetrag Gesamtbetrag
</dt> </dt>
<dd className="mt-1 text-sm font-semibold"> <dd className="mt-1 text-sm font-semibold">
@@ -147,14 +155,14 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-muted-foreground py-8 text-center text-sm"> <p className="py-8 text-center text-sm text-muted-foreground">
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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium"> <th className="p-3 text-left font-medium">
Beschreibung Beschreibung
</th> </th>
@@ -169,7 +177,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="hover:bg-muted/30 border-b" className="border-b hover:bg-muted/30"
> >
<td className="p-3"> <td className="p-3">
{String(item.description ?? '—')} {String(item.description ?? '—')}
@@ -191,7 +199,7 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
))} ))}
</tbody> </tbody>
<tfoot> <tfoot>
<tr className="bg-muted/30 border-t"> <tr className="border-t bg-muted/30">
<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,29 +1,18 @@
import { CreateInvoiceForm } from '@kit/finance/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateInvoiceForm } from '@kit/finance/components';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props { params: Promise<{ account: string }> }
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 const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell <CmsPageShell account={account} title="Neue Rechnung" description="Rechnung mit Positionen erstellen">
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 { AccountNotFound } from '~/components/account-not-found'; import { createFinanceApi } from '@kit/finance/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 { 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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Nr.</th> <th className="p-3 text-left font-medium">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="hover:bg-muted/30 border-b" className="border-b hover:bg-muted/30"
> >
<td className="p-3 font-mono text-xs"> <td className="p-3 font-mono text-xs">
<Link <Link
@@ -106,10 +106,18 @@ 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">
{formatDate(invoice.issue_date)} {invoice.issue_date
? new Date(
String(invoice.issue_date),
).toLocaleDateString('de-DE')
: '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
{formatDate(invoice.due_date)} {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 { AccountNotFound } from '~/components/account-not-found'; import { createFinanceApi } from '@kit/finance/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';
import { import {
BATCH_STATUS_VARIANT, BATCH_STATUS_VARIANT,
BATCH_STATUS_LABEL, BATCH_STATUS_LABEL,
@@ -61,7 +61,9 @@ 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">SEPA-Einzüge und Rechnungen</p> <p className="text-muted-foreground">
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`}>
@@ -122,7 +124,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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Status</th> <th className="p-3 text-left font-medium">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>
@@ -133,17 +135,15 @@ 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="hover:bg-muted/30 border-b" className="border-b hover:bg-muted/30"
> >
<td className="p-3"> <td className="p-3">
<Badge <Badge
variant={ variant={
BATCH_STATUS_VARIANT[String(batch.status)] ?? BATCH_STATUS_VARIANT[String(batch.status)] ?? 'secondary'
'secondary'
} }
> >
{BATCH_STATUS_LABEL[String(batch.status)] ?? {BATCH_STATUS_LABEL[String(batch.status)] ?? String(batch.status)}
String(batch.status)}
</Badge> </Badge>
</td> </td>
<td className="p-3"> <td className="p-3">
@@ -157,7 +157,11 @@ export default async function FinancePage({ params }: PageProps) {
: '—'} : '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
{formatDate(batch.execution_date ?? batch.created_at)} {batch.execution_date
? new Date(String(batch.execution_date)).toLocaleDateString('de-DE')
: batch.created_at
? new Date(String(batch.created_at)).toLocaleDateString('de-DE')
: '—'}
</td> </td>
</tr> </tr>
))} ))}
@@ -192,7 +196,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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Nr.</th> <th className="p-3 text-left font-medium">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>
@@ -203,7 +207,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="hover:bg-muted/30 border-b" className="border-b hover:bg-muted/30"
> >
<td className="p-3 font-mono text-xs"> <td className="p-3 font-mono text-xs">
<Link <Link

View File

@@ -2,15 +2,16 @@ 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 { AccountNotFound } from '~/components/account-not-found'; import { createFinanceApi } from '@kit/finance/api';
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 }>;
@@ -119,14 +120,12 @@ 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 <Badge variant={openInvoices.length > 0 ? 'default' : 'secondary'}>
variant={openInvoices.length > 0 ? 'default' : 'secondary'}
>
{openInvoices.length} {openInvoices.length}
</Badge> </Badge>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-muted-foreground mb-4 text-sm"> <p className="mb-4 text-sm text-muted-foreground">
{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.'}
@@ -148,7 +147,7 @@ export default async function PaymentsPage({ params }: PageProps) {
</Badge> </Badge>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-muted-foreground mb-4 text-sm"> <p className="mb-4 text-sm text-muted-foreground">
{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 { AccountNotFound } from '~/components/account-not-found'; import { createFinanceApi } from '@kit/finance/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
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 <AccountNotFound />; if (!batch) return <div>Einzug nicht gefunden</div>;
const status = String(batch.status); const status = String(batch.status);
@@ -95,7 +95,9 @@ 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>{String(batch.description ?? 'SEPA-Einzug')}</CardTitle> <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>
@@ -103,7 +105,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-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Typ Typ
</dt> </dt>
<dd className="mt-1 text-sm font-semibold"> <dd className="mt-1 text-sm font-semibold">
@@ -113,7 +115,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Betrag Betrag
</dt> </dt>
<dd className="mt-1 text-sm font-semibold"> <dd className="mt-1 text-sm font-semibold">
@@ -123,7 +125,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Anzahl Anzahl
</dt> </dt>
<dd className="mt-1 text-sm font-semibold"> <dd className="mt-1 text-sm font-semibold">
@@ -131,11 +133,15 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Ausführungsdatum Ausführungsdatum
</dt> </dt>
<dd className="mt-1 text-sm font-semibold"> <dd className="mt-1 text-sm font-semibold">
{formatDate(batch.execution_date)} {batch.execution_date
? new Date(
String(batch.execution_date),
).toLocaleDateString('de-DE')
: '—'}
</dd> </dd>
</div> </div>
</dl> </dl>
@@ -156,14 +162,14 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-muted-foreground py-8 text-center text-sm"> <p className="py-8 text-center text-sm text-muted-foreground">
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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th> <th className="p-3 text-left font-medium">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>
@@ -176,7 +182,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
return ( return (
<tr <tr
key={String(item.id)} key={String(item.id)}
className="hover:bg-muted/30 border-b" className="border-b hover:bg-muted/30"
> >
<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 { AccountNotFound } from '~/components/account-not-found'; import { CreateSepaBatchForm } from '@kit/finance/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -21,11 +21,7 @@ export default async function NewSepaBatchPage({ params }: Props) {
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell <CmsPageShell account={account} title="Neuer SEPA-Einzug" description="SEPA-Lastschrifteinzug erstellen">
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,17 +2,20 @@ 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 { AccountNotFound } from '~/components/account-not-found'; import { createFinanceApi } from '@kit/finance/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 { BATCH_STATUS_VARIANT, BATCH_STATUS_LABEL } from '~/lib/status-badges'; import { AccountNotFound } from '~/components/account-not-found';
import {
BATCH_STATUS_VARIANT,
BATCH_STATUS_LABEL,
} from '~/lib/status-badges';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -76,7 +79,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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Status</th> <th className="p-3 text-left font-medium">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">
@@ -93,13 +96,12 @@ 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="hover:bg-muted/30 border-b" className="border-b hover:bg-muted/30"
> >
<td className="p-3"> <td className="p-3">
<Badge <Badge
variant={ variant={
BATCH_STATUS_VARIANT[String(batch.status)] ?? BATCH_STATUS_VARIANT[String(batch.status)] ?? 'secondary'
'secondary'
} }
> >
{BATCH_STATUS_LABEL[String(batch.status)] ?? {BATCH_STATUS_LABEL[String(batch.status)] ??
@@ -128,7 +130,11 @@ 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">
{formatDate(batch.execution_date)} {batch.execution_date
? new Date(
String(batch.execution_date),
).toLocaleDateString('de-DE')
: '—'}
</td> </td>
</tr> </tr>
))} ))}

View File

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

View File

@@ -1,22 +1,16 @@
import { createFischereiApi } from '@kit/fischerei/api';
import {
FischereiTabNavigation,
CompetitionsDataTable,
} from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createFischereiApi } from '@kit/fischerei/api';
import { FischereiTabNavigation, CompetitionsDataTable } from '@kit/fischerei/components';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
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({ export default async function CompetitionsPage({ params, searchParams }: Props) {
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 { formatDate } from '@kit/shared/dates'; import { CmsPageShell } from '~/components/cms-page-shell';
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="text-muted-foreground mt-1 max-w-sm text-sm"> <p className="mt-1 max-w-sm text-sm text-muted-foreground">
Erstellen Sie Ihren ersten Pachtvertrag. Erstellen Sie Ihren ersten Pachtvertrag.
</p> </p>
</div> </div>
@@ -62,32 +62,22 @@ 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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<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"> <th className="p-3 text-right font-medium">Jahresbetrag ()</th>
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< const waters = lease.waters as Record<string, unknown> | null;
string, const paymentMethod = String(lease.payment_method ?? 'ueberweisung');
unknown
> | null;
const paymentMethod = String(
lease.payment_method ?? 'ueberweisung',
);
return ( return (
<tr <tr key={String(lease.id)} className="border-b hover:bg-muted/30">
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>
@@ -95,11 +85,13 @@ 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">
{formatDate(lease.start_date)} {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
? formatDate(lease.end_date) ? new Date(String(lease.end_date)).toLocaleDateString('de-DE')
: 'unbefristet'} : 'unbefristet'}
</td> </td>
<td className="p-3 text-right"> <td className="p-3 text-right">
@@ -109,8 +101,7 @@ 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] ?? {LEASE_PAYMENT_LABELS[paymentMethod] ?? paymentMethod}
paymentMethod}
</Badge> </Badge>
</td> </td>
</tr> </tr>

View File

@@ -1,12 +1,9 @@
import { createFischereiApi } from '@kit/fischerei/api';
import {
FischereiTabNavigation,
FischereiDashboard,
} from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createFischereiApi } from '@kit/fischerei/api';
import { FischereiTabNavigation, FischereiDashboard } from '@kit/fischerei/components';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
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 { 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 { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
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="text-muted-foreground mt-1 max-w-sm text-sm"> <p className="mt-1 max-w-sm text-sm text-muted-foreground">
Erstellen Sie Ihren ersten Erlaubnisschein. Erstellen Sie Ihren ersten Erlaubnisschein.
</p> </p>
</div> </div>
@@ -53,36 +53,22 @@ 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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<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"> <th className="p-3 text-left font-medium">Hauptgewässer</th>
Hauptgewässer <th className="p-3 text-right font-medium">Gesamtmenge</th>
</th> <th className="p-3 text-center font-medium">Zum Verkauf</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< const waters = permit.waters as Record<string, unknown> | null;
string,
unknown
> | null;
return ( return (
<tr <tr key={String(permit.id)} className="border-b hover:bg-muted/30">
key={String(permit.id)} <td className="p-3 font-medium">{String(permit.name)}</td>
className="hover:bg-muted/30 border-b" <td className="p-3 text-muted-foreground">
>
<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,11 +1,8 @@
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 { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;

View File

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

View File

@@ -1,9 +1,9 @@
import { FischereiTabNavigation } from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { FischereiTabNavigation } from '@kit/fischerei/components';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -37,12 +37,9 @@ 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"> <h3 className="text-lg font-semibold">Noch keine Daten vorhanden</h3>
Noch keine Daten vorhanden <p className="mt-1 max-w-sm text-sm text-muted-foreground">
</h3> Sobald Fangbücher eingereicht und geprüft werden, erscheinen hier Statistiken und Auswertungen.
<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,12 +1,9 @@
import { createFischereiApi } from '@kit/fischerei/api';
import {
FischereiTabNavigation,
CreateStockingForm,
} from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createFischereiApi } from '@kit/fischerei/api';
import { FischereiTabNavigation, CreateStockingForm } from '@kit/fischerei/components';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;

View File

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

View File

@@ -1,11 +1,8 @@
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 { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;

View File

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

View File

@@ -70,8 +70,7 @@ 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) if (!features.fischerei && !features.meetings && !features.verband) return config;
return config;
const featureEntries: Array<{ const featureEntries: Array<{
label: string; label: string;
@@ -161,10 +160,10 @@ async function SidebarLayout({
/> />
</PageNavigation> </PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}> <PageMobileNavigation>
<AppLogo /> <AppLogo />
<div className={'flex space-x-4'}> <div className={'flex'}>
<TeamAccountLayoutMobileNavigation <TeamAccountLayoutMobileNavigation
userId={data.user.id} userId={data.user.id}
accounts={accounts} accounts={accounts}
@@ -195,6 +194,12 @@ async function HeaderLayout({
const baseConfig = getTeamAccountSidebarConfig(account); const baseConfig = getTeamAccountSidebarConfig(account);
const config = injectAccountFeatureRoutes(baseConfig, account, features); const config = injectAccountFeatureRoutes(baseConfig, account, features);
const accounts = data.accounts.map(({ name, slug, picture_url }) => ({
label: name,
value: slug,
image: picture_url,
}));
return ( return (
<TeamAccountWorkspaceContextProvider value={data}> <TeamAccountWorkspaceContextProvider value={data}>
<Page style={'header'}> <Page style={'header'}>
@@ -202,6 +207,20 @@ async function HeaderLayout({
<TeamAccountNavigationMenu workspace={data} config={config} /> <TeamAccountNavigationMenu workspace={data} config={config} />
</PageNavigation> </PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>
<div>
<AppLogo />
</div>
<div>
<TeamAccountLayoutMobileNavigation
userId={data.user.id}
accounts={accounts}
account={account}
/>
</div>
</PageMobileNavigation>
{children} {children}
</Page> </Page>
</TeamAccountWorkspaceContextProvider> </TeamAccountWorkspaceContextProvider>

View File

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

View File

@@ -2,21 +2,19 @@ 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 { AccountNotFound } from '~/components/account-not-found'; 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 { CmsPageShell } from '~/components/cms-page-shell';
import { MEETING_TYPE_LABELS } from '@kit/sitzungsprotokolle/lib/meetings-constants';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string; protocolId: string }>; params: Promise<{ account: string; protocolId: string }>;
} }
@@ -41,12 +39,9 @@ export default async function ProtocolDetailPage({ params }: PageProps) {
} catch { } catch {
return ( return (
<CmsPageShell account={account} title="Sitzungsprotokolle"> <CmsPageShell account={account} title="Sitzungsprotokolle">
<div className="py-12 text-center"> <div className="text-center py-12">
<h2 className="text-lg font-semibold">Protokoll nicht gefunden</h2> <h2 className="text-lg font-semibold">Protokoll nicht gefunden</h2>
<Link <Link href={`/home/${account}/meetings/protocols`} className="mt-4 inline-block">
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>
@@ -77,12 +72,18 @@ 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="text-muted-foreground mt-2 flex flex-wrap items-center gap-2 text-sm"> <div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{formatDateFull(protocol.meeting_date)}</span> <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] ?? {MEETING_TYPE_LABELS[protocol.meeting_type] ?? 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>
@@ -96,28 +97,20 @@ 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-muted-foreground text-sm font-medium">Ort</p> <p className="text-sm font-medium text-muted-foreground">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-muted-foreground text-sm font-medium"> <p className="text-sm font-medium text-muted-foreground">Teilnehmer</p>
Teilnehmer <p className="text-sm whitespace-pre-line">{protocol.attendees}</p>
</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-muted-foreground text-sm font-medium"> <p className="text-sm font-medium text-muted-foreground">Anmerkungen</p>
Anmerkungen <p className="text-sm whitespace-pre-line">{protocol.remarks}</p>
</p>
<p className="text-sm whitespace-pre-line">
{protocol.remarks}
</p>
</div> </div>
)} )}
</CardContent> </CardContent>

View File

@@ -1,11 +1,8 @@
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 { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;

View File

@@ -1,22 +1,16 @@
import { createMeetingsApi } from '@kit/sitzungsprotokolle/api';
import {
MeetingsTabNavigation,
ProtocolsDataTable,
} from '@kit/sitzungsprotokolle/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMeetingsApi } from '@kit/sitzungsprotokolle/api';
import { MeetingsTabNavigation, ProtocolsDataTable } from '@kit/sitzungsprotokolle/components';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
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({ export default async function ProtocolsPage({ params, searchParams }: PageProps) {
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,12 +1,9 @@
import { createMeetingsApi } from '@kit/sitzungsprotokolle/api';
import {
MeetingsTabNavigation,
OpenTasksView,
} from '@kit/sitzungsprotokolle/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMeetingsApi } from '@kit/sitzungsprotokolle/api';
import { MeetingsTabNavigation, OpenTasksView } from '@kit/sitzungsprotokolle/components';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -39,8 +36,7 @@ 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 Alle offenen und in Bearbeitung befindlichen Tagesordnungspunkte über alle Protokolle.
über alle Protokolle.
</p> </p>
</div> </div>
<OpenTasksView <OpenTasksView

View File

@@ -1,9 +1,8 @@
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 { 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 { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props {
params: Promise<{ account: string; memberId: string }>; params: Promise<{ account: string; memberId: string }>;
@@ -12,11 +11,7 @@ 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 const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); const api = createMemberManagementApi(client);
@@ -24,10 +19,7 @@ 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 <CmsPageShell account={account} title={`${String(member.first_name)} ${String(member.last_name)} bearbeiten`}>
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,9 +1,8 @@
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 { 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 { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props {
params: Promise<{ account: string; memberId: string }>; params: Promise<{ account: string; memberId: string }>;
@@ -12,11 +11,7 @@ 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 const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); const api = createMemberManagementApi(client);
@@ -24,10 +19,7 @@ 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 <CmsPageShell account={account} title={`${String(member.first_name)} ${String(member.last_name)}`}>
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,9 +1,8 @@
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 { 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 { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -12,27 +11,15 @@ 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 const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
.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 <CmsPageShell account={account} title="Aufnahmeanträge" description="Mitgliedsanträge bearbeiten">
account={account} <ApplicationWorkflow applications={applications} accountId={acct.id} account={account} />
title="Aufnahmeanträge"
description="Mitgliedsanträge bearbeiten"
>
<ApplicationWorkflow
applications={applications}
accountId={acct.id}
account={account}
/>
</CmsPageShell> </CmsPageShell>
); );
} }

View File

@@ -1,11 +1,9 @@
import { CreditCard } 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 { createMemberManagementApi } from '@kit/member-management/api';
import { AccountNotFound } from '~/components/account-not-found'; import { CreditCard } from 'lucide-react';
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;
@@ -17,26 +15,15 @@ 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 const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
.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, { const result = await api.listMembers(acct.id, { status: 'active', pageSize: CARDS_PAGE_SIZE });
status: 'active',
pageSize: CARDS_PAGE_SIZE,
});
const members = result.data; const members = result.data;
return ( return (
<CmsPageShell <CmsPageShell account={account} title="Mitgliedsausweise" description="Ausweise erstellen und verwalten">
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,14 +1,12 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Plus } from 'lucide-react';
import { useAction } from 'next-safe-action/hooks'; import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { createDepartment } from '@kit/member-management/actions/member-actions'; import { toast } from '@kit/ui/sonner';
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,
@@ -18,17 +16,15 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@kit/ui/dialog'; } from '@kit/ui/dialog';
import { Input } from '@kit/ui/input'; import { Plus } from 'lucide-react';
import { Label } from '@kit/ui/label';
import { toast } from '@kit/ui/sonner'; import { createDepartment } from '@kit/member-management/actions/member-actions';
interface CreateDepartmentDialogProps { interface CreateDepartmentDialogProps {
accountId: string; accountId: string;
} }
export function CreateDepartmentDialog({ export function CreateDepartmentDialog({ accountId }: CreateDepartmentDialogProps) {
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('');
@@ -53,11 +49,7 @@ export function CreateDepartmentDialog({
(e: React.FormEvent) => { (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!name.trim()) return; if (!name.trim()) return;
execute({ execute({ accountId, name: name.trim(), description: description.trim() || undefined });
accountId,
name: name.trim(),
description: description.trim() || undefined,
});
}, },
[execute, accountId, name, description], [execute, accountId, name, description],
); );

View File

@@ -1,11 +1,9 @@
import { Users } 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 { 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 { 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';
@@ -16,22 +14,14 @@ 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 const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
.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 <CmsPageShell account={account} title="Abteilungen" description="Sparten und Abteilungen verwalten">
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} />
@@ -47,21 +37,16 @@ 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="bg-muted/50 border-b"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th> <th className="p-3 text-left font-medium">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 <tr key={String(dept.id)} className="border-b hover:bg-muted/30">
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="text-muted-foreground p-3"> <td className="p-3 text-muted-foreground">{String(dept.description ?? '—')}</td>
{String(dept.description ?? '—')}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -1,9 +1,8 @@
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 { 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 { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -12,22 +11,14 @@ 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 const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
.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 <CmsPageShell account={account} title="Beitragskategorien" description="Mitgliedsbeiträge verwalten">
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,8 +1,7 @@
import { MemberImportWizard } from '@kit/member-management/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { MemberImportWizard } from '@kit/member-management/components';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -11,19 +10,11 @@ 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 const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell <CmsPageShell account={account} title="Mitglieder importieren" description="CSV-Datei importieren">
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,43 +1,28 @@
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 { 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 { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props { params: Promise<{ account: string }> }
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 const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
.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 <CmsPageShell account={account} title="Neues Mitglied" description="Mitglied manuell anlegen">
account={account}
title="Neues Mitglied"
description="Mitglied manuell anlegen"
>
<CreateMemberForm <CreateMemberForm
accountId={acct.id} accountId={acct.id}
account={account} account={account}
duesCategories={(duesCategories ?? []).map( duesCategories={(duesCategories ?? []).map((c: Record<string, unknown>) => ({
(c: Record<string, unknown>) => ({ id: String(c.id), name: String(c.name), amount: Number(c.amount ?? 0)
id: String(c.id), }))}
name: String(c.name),
amount: Number(c.amount ?? 0),
}),
)}
/> />
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -1,9 +1,8 @@
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 { 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 { AccountNotFound } from '~/components/account-not-found';
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
@@ -16,11 +15,7 @@ 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 const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); const api = createMemberManagementApi(client);
@@ -34,23 +29,16 @@ 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 <CmsPageShell account={account} title="Mitglieder" description={`${result.total} Mitglieder`}>
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( duesCategories={(duesCategories ?? []).map((c: Record<string, unknown>) => ({
(c: Record<string, unknown>) => ({ id: String(c.id), name: String(c.name),
id: String(c.id), }))}
name: String(c.name),
}),
)}
/> />
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -1,20 +1,14 @@
import { import { Users, UserCheck, UserMinus, Clock, BarChart3, TrendingUp } from 'lucide-react';
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 { AccountNotFound } from '~/components/account-not-found'; import { createMemberManagementApi } from '@kit/member-management/api';
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 }>;
@@ -46,26 +40,10 @@ 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 <StatsCard title="Gesamt" value={stats.total ?? 0} icon={<Users className="h-5 w-5" />} />
title="Gesamt" <StatsCard title="Aktiv" value={stats.active ?? 0} icon={<UserCheck className="h-5 w-5" />} />
value={stats.total ?? 0} <StatsCard title="Inaktiv" value={stats.inactive ?? 0} icon={<UserMinus className="h-5 w-5" />} />
icon={<Users className="h-5 w-5" />} <StatsCard title="Ausstehend" value={stats.pending ?? 0} icon={<Clock 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,8 +1,6 @@
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';
@@ -51,11 +49,7 @@ async function loadAccountMembers(
}); });
if (error) { if (error) {
const logger = await getLogger(); console.error(error);
logger.error(
{ error, context: 'load-account-members' },
'Failed to load account members',
);
throw error; throw error;
} }
@@ -76,11 +70,7 @@ async function loadInvitations(
}); });
if (error) { if (error) {
const logger = await getLogger(); console.error(error);
logger.error(
{ error, context: 'load-invitations' },
'Failed to load account invitations',
);
throw error; throw error;
} }

View File

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

View File

@@ -1,9 +1,9 @@
import { Upload, ArrowRight, CheckCircle } from 'lucide-react'; 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';
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 { Button } from '@kit/ui/button';
import { Upload, ArrowRight, CheckCircle } from 'lucide-react';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
@@ -19,45 +19,22 @@ 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 = const fields = (moduleWithFields as unknown as { fields: Array<{ name: string; display_name: string }> }).fields ?? [];
(
moduleWithFields as unknown as {
fields: Array<{ name: string; display_name: string }>;
}
).fields ?? [];
return ( return (
<CmsPageShell <CmsPageShell account={account} title={`${String(moduleWithFields.display_name)} — Import`}>
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 <div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold ${
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 === 0 }`}>
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
{i + 1} {i + 1}
</div> </div>
<span <span className={`text-sm ${i === 0 ? 'font-semibold' : 'text-muted-foreground'}`}>{step}</span>
className={`text-sm ${i === 0 ? 'font-semibold' : 'text-muted-foreground'}`} {i < 3 && <ArrowRight className="h-4 w-4 text-muted-foreground" />}
>
{step}
</span>
{i < 3 && (
<ArrowRight className="text-muted-foreground h-4 w-4" />
)}
</div> </div>
))} ))}
</div> </div>
@@ -72,30 +49,21 @@ 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="text-muted-foreground mb-4 h-10 w-10" /> <Upload className="mb-4 h-10 w-10 text-muted-foreground" />
<p className="text-lg font-semibold"> <p className="text-lg font-semibold">CSV oder Excel-Datei hierher ziehen</p>
CSV oder Excel-Datei hierher ziehen <p className="mt-1 text-sm text-muted-foreground">oder klicken zum Auswählen</p>
</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="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" className="mt-4 block w-full max-w-xs text-sm text-muted-foreground file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground hover:file:bg-primary/90"
/> />
</div> </div>
<div className="mt-6"> <div className="mt-6">
<h4 className="mb-2 text-sm font-semibold"> <h4 className="text-sm font-semibold mb-2">Verfügbare Zielfelder:</h4>
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 <span key={field.name} className="rounded-md bg-muted px-2 py-1 text-xs">
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 { createModuleBuilderApi } from '@kit/module-builder/api';
import { ModuleForm } from '@kit/module-builder/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createModuleBuilderApi } from '@kit/module-builder/api';
import { ModuleForm } from '@kit/module-builder/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
interface NewRecordPageProps { interface NewRecordPageProps {
@@ -16,30 +16,18 @@ 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 = ( const fields = (moduleWithFields as unknown as {
moduleWithFields as unknown as {
fields: Array<{ fields: Array<{
name: string; name: string; display_name: string; field_type: string;
display_name: string; is_required: boolean; placeholder: string | null;
field_type: string; help_text: string | null; is_readonly: boolean;
is_required: boolean;
placeholder: string | null;
help_text: string | null;
is_readonly: boolean;
select_options: Array<{ label: string; value: string }> | null; select_options: Array<{ label: string; value: string }> | null;
section: string; section: string; sort_order: number; show_in_form: boolean; width: string;
sort_order: number;
show_in_form: boolean;
width: string;
}>; }>;
} }).fields;
).fields;
return ( return (
<CmsPageShell <CmsPageShell account={account} title={`${String(moduleWithFields.display_name)} — Neuer Datensatz`}>
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']}

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