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 ---
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 ---
# All default to true, set to false to disable
# NEXT_PUBLIC_ENABLE_MODULE_BUILDER=true

View File

@@ -4,7 +4,7 @@ WORKDIR /app
# --- Install + Build in one stage ---
FROM base AS builder
ARG CACHE_BUST=6
ARG CACHE_BUST=7
COPY . .
RUN pnpm install --no-frozen-lockfile
ENV NEXT_TELEMETRY_DISABLED=1

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ EMAIL_PASSWORD=password
# STRIPE
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

View File

@@ -78,7 +78,7 @@ function Home() {
{/* Trust Indicators */}
<div className={'container mx-auto'}>
<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'} />
</p>
@@ -89,7 +89,10 @@ function Home() {
label="marketing.trustSchools"
/>
<TrustItem icon={BookOpenIcon} label="marketing.trustClubs" />
<TrustItem icon={GlobeIcon} label="marketing.trustOrganizations" />
<TrustItem
icon={GlobeIcon}
label="marketing.trustOrganizations"
/>
</div>
</div>
</div>
@@ -181,7 +184,9 @@ function Home() {
</b>
.{' '}
<span className="text-secondary-foreground/70 block font-normal tracking-tight">
<Trans i18nKey={'marketing.additionalFeaturesSubheading'} />
<Trans
i18nKey={'marketing.additionalFeaturesSubheading'}
/>
</span>
</>
}
@@ -251,7 +256,7 @@ function Home() {
<div className="container mx-auto">
<div className="flex flex-col items-center gap-12">
<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'} />
</h2>
<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 */}
<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">
<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'} />
</h2>
<p className="text-secondary-foreground/70 max-w-2xl text-lg">

View File

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

View File

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

View File

@@ -8,27 +8,23 @@ export default async function AdminMigrationPage() {
</p>
</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>
<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>Mandanten (user_profile team accounts) zuordnen</li>
<li>Benutzer (cms_user auth.users) migrieren</li>
<li>
Module (m_module/m_modulfeld modules/module_fields) übertragen
</li>
<li>Module (m_module/m_modulfeld modules/module_fields) übertragen</li>
<li>Mitglieder (ve_mitglieder members) importieren</li>
<li>Kurse (ve_kurse courses) importieren</li>
<li>Dateien (cms_files Supabase Storage) hochladen</li>
<li>Daten verifizieren und bereinigen</li>
</ol>
<div className="rounded-md 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">
<strong>Hinweis:</strong> Die Migration erfordert eine
MySQL-Verbindung zum Legacy-System. Stellen Sie sicher, dass{' '}
<code>mysql2</code> installiert ist und die Verbindungsdaten korrekt
konfiguriert sind.
<strong>Hinweis:</strong> Die Migration erfordert eine MySQL-Verbindung zum Legacy-System.
Stellen Sie sicher, dass <code>mysql2</code> installiert ist und die Verbindungsdaten korrekt konfiguriert sind.
</p>
</div>
</div>

View File

@@ -9,10 +9,9 @@ export default async function AdminModulesPage() {
</div>
<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.
Ermöglicht die zentrale Verwaltung von Modulvorlagen und
-konfigurationen.
Ermöglicht die zentrale Verwaltung von Modulvorlagen und -konfigurationen.
</p>
</div>
</div>

View File

@@ -1,13 +1,9 @@
import { notFound } from 'next/navigation';
import { createClient } from '@supabase/supabase-js';
import { notFound } from 'next/navigation';
import { SiteRenderer } from '@kit/site-builder/components';
import type { SiteData } from '@kit/site-builder/context';
interface Props {
params: Promise<{ slug: string; page: string[] }>;
}
interface Props { params: Promise<{ slug: string; page: string[] }> }
export default async function ClubSubPage({ params }: Props) {
const { slug, page: pagePath } = await params;
@@ -18,73 +14,36 @@ export default async function ClubSubPage({ params }: Props) {
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
);
const { data: account } = await supabase
.from('accounts')
.select('id')
.eq('slug', slug)
.single();
const { data: account } = await supabase.from('accounts').select('id').eq('slug', slug).single();
if (!account) notFound();
const { data: settings } = await supabase
.from('site_settings')
.select('*')
.eq('account_id', account.id)
.eq('is_public', true)
.maybeSingle();
const { data: settings } = await supabase.from('site_settings').select('*').eq('account_id', account.id).eq('is_public', true).maybeSingle();
if (!settings) notFound();
const { data: sitePageData } = await supabase
.from('site_pages')
.select('*')
.eq('account_id', account.id)
.eq('slug', pageSlug)
.eq('is_published', true)
.maybeSingle();
const { data: sitePageData } = await supabase.from('site_pages').select('*')
.eq('account_id', account.id).eq('slug', pageSlug).eq('is_published', true).maybeSingle();
if (!sitePageData) notFound();
// Pre-fetch CMS data for Puck components
const [eventsRes, coursesRes, postsRes] = await Promise.all([
supabase
.from('events')
.select('id, name, event_date, event_time, location, fee, status')
.eq('account_id', account.id)
.order('event_date', { ascending: true })
.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),
supabase.from('events').select('id, name, event_date, event_time, location, fee, status')
.eq('account_id', account.id).order('event_date', { ascending: true }).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 = {
accountId: account.id,
events: eventsRes.data ?? [],
courses: (coursesRes.data ?? []).map((c) => ({ ...c, enrolled_count: 0 })),
courses: (coursesRes.data ?? []).map(c => ({ ...c, enrolled_count: 0 })),
posts: postsRes.data ?? [],
};
return (
<div
style={
{
'--primary': settings.primary_color,
fontFamily: settings.font_family,
} as React.CSSProperties
}
>
<SiteRenderer
data={(sitePageData.puck_data ?? {}) as Record<string, unknown>}
siteData={siteData}
/>
<div style={{ '--primary': settings.primary_color, fontFamily: settings.font_family } as React.CSSProperties}>
<SiteRenderer data={(sitePageData.puck_data ?? {}) as Record<string, unknown>} siteData={siteData} />
</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 { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Button } from '@kit/ui/button';
import { Mail } from 'lucide-react';
interface Props {
params: Promise<{ slug: string }>;
}
interface Props { params: Promise<{ slug: string }> }
export default async function NewsletterSubscribePage({ params }: Props) {
const { slug } = await params;
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">
<CardHeader className="text-center">
<div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
<Mail className="text-primary h-6 w-6" />
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Mail className="h-6 w-6 text-primary" />
</div>
<CardTitle>Newsletter abonnieren</CardTitle>
<p className="text-muted-foreground text-sm">
Bleiben Sie über Neuigkeiten informiert.
</p>
<p className="text-sm text-muted-foreground">Bleiben Sie über Neuigkeiten informiert.</p>
</CardHeader>
<CardContent>
<form className="space-y-4">
@@ -32,19 +27,11 @@ export default async function NewsletterSubscribePage({ params }: Props) {
</div>
<div className="space-y-2">
<Label>E-Mail-Adresse *</Label>
<Input
name="email"
type="email"
placeholder="ihre@email.de"
required
/>
<Input name="email" type="email" placeholder="ihre@email.de" required />
</div>
<Button type="submit" className="w-full">
Abonnieren
</Button>
<p className="text-muted-foreground text-center text-xs">
Sie können sich jederzeit abmelden. Wir senden Ihnen eine
Bestätigungs-E-Mail.
<Button type="submit" className="w-full">Abonnieren</Button>
<p className="text-xs text-center text-muted-foreground">
Sie können sich jederzeit abmelden. Wir senden Ihnen eine Bestätigungs-E-Mail.
</p>
</form>
</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 { MailX } from 'lucide-react';
interface Props { params: Promise<{ slug: string }>; searchParams: Promise<{ token?: string }> }
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
interface Props {
params: Promise<{ slug: string }>;
searchParams: Promise<{ token?: string }>;
}
export default async function NewsletterUnsubscribePage({
params,
searchParams,
}: Props) {
export default async function NewsletterUnsubscribePage({ params, searchParams }: Props) {
const { slug } = await params;
const { token } = await searchParams;
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">
<CardHeader>
<div className="bg-destructive/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
<MailX className="text-destructive h-6 w-6" />
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<MailX className="h-6 w-6 text-destructive" />
</div>
<CardTitle>Newsletter abbestellen</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{token ? (
<>
<p className="text-muted-foreground text-sm">
Möchten Sie den Newsletter wirklich abbestellen?
</p>
<Button variant="destructive" className="w-full">
Abbestellen bestätigen
</Button>
<p className="text-sm text-muted-foreground">Möchten Sie den Newsletter wirklich abbestellen?</p>
<Button variant="destructive" className="w-full">Abbestellen bestätigen</Button>
</>
) : (
<p className="text-muted-foreground text-sm">
Kein gültiger Abmeldelink. Bitte verwenden Sie den Link aus der
Newsletter-E-Mail.
</p>
<p className="text-sm text-muted-foreground">Kein gültiger Abmeldelink. Bitte verwenden Sie den Link aus der Newsletter-E-Mail.</p>
)}
<Link href={`/club/${slug}`}>
<Button variant="outline" size="sm">
Zurück zur Website
</Button>
<Button variant="outline" size="sm"> Zurück zur Website</Button>
</Link>
</CardContent>
</Card>

View File

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

View File

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

View File

@@ -1,25 +1,18 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { createClient } from '@supabase/supabase-js';
import { UserPlus, Shield, CheckCircle } from 'lucide-react';
import { formatDate } from '@kit/shared/dates';
import { Button } from '@kit/ui/button';
import { notFound } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { UserPlus, Shield, CheckCircle } from 'lucide-react';
import Link from 'next/link';
interface Props {
params: Promise<{ slug: string }>;
searchParams: Promise<{ token?: string }>;
}
export default async function PortalInvitePage({
params,
searchParams,
}: Props) {
export default async function PortalInvitePage({ params, searchParams }: Props) {
const { slug } = await params;
const { token } = await searchParams;
@@ -31,35 +24,28 @@ export default async function PortalInvitePage({
);
// Resolve account
const { data: account } = await supabase
.from('accounts')
.select('id, name')
.eq('slug', slug)
.single();
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
if (!account) notFound();
// Look up invitation
const { data: invitation } = await supabase
.from('member_portal_invitations')
const { data: invitation } = await supabase.from('member_portal_invitations')
.select('id, email, status, expires_at, member_id')
.eq('invite_token', token)
.maybeSingle();
if (!invitation || invitation.status !== 'pending') {
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">
<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>
<p className="text-muted-foreground mt-2 text-sm">
Diese Einladung ist abgelaufen, wurde bereits verwendet oder ist
ungültig. Bitte wenden Sie sich an Ihren Vereinsadministrator.
<p className="text-sm text-muted-foreground mt-2">
Diese Einladung ist abgelaufen, wurde bereits verwendet oder ist ungültig.
Bitte wenden Sie sich an Ihren Vereinsadministrator.
</p>
<Link href={`/club/${slug}`}>
<Button variant="outline" className="mt-4">
Zur Website
</Button>
<Button variant="outline" className="mt-4"> Zur Website</Button>
</Link>
</CardContent>
</Card>
@@ -70,14 +56,14 @@ export default async function PortalInvitePage({
const expired = new Date(invitation.expires_at) < new Date();
if (expired) {
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">
<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>
<p className="text-muted-foreground mt-2 text-sm">
Diese Einladung ist am {formatDate(invitation.expires_at)}{' '}
abgelaufen. Bitte fordern Sie eine neue Einladung an.
<p className="text-sm text-muted-foreground mt-2">
Diese Einladung ist am {new Date(invitation.expires_at).toLocaleDateString('de-DE')} abgelaufen.
Bitte fordern Sie eine neue Einladung an.
</p>
</CardContent>
</Card>
@@ -86,67 +72,41 @@ export default async function PortalInvitePage({
}
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">
<CardHeader className="text-center">
<div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
<UserPlus className="text-primary h-6 w-6" />
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<UserPlus className="h-6 w-6 text-primary" />
</div>
<CardTitle>Einladung zum Mitgliederbereich</CardTitle>
<p className="text-muted-foreground text-sm">
{String(account.name)}
</p>
<p className="text-sm text-muted-foreground">{String(account.name)}</p>
</CardHeader>
<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">
Sie wurden eingeladen, ein Konto für den Mitgliederbereich zu
erstellen. Damit können Sie Ihr Profil einsehen, Dokumente
herunterladen und Ihre Datenschutz-Einstellungen verwalten.
Sie wurden eingeladen, ein Konto für den Mitgliederbereich zu erstellen.
Damit können Sie Ihr Profil einsehen, Dokumente herunterladen und Ihre Datenschutz-Einstellungen verwalten.
</p>
</div>
<form
className="space-y-4"
action={`/api/club/accept-invite`}
method="POST"
>
<form className="space-y-4" action={`/api/club/accept-invite`} method="POST">
<input type="hidden" name="token" value={token} />
<input type="hidden" name="slug" value={slug} />
<div className="space-y-2">
<Label>E-Mail-Adresse</Label>
<Input
type="email"
value={invitation.email}
readOnly
className="bg-muted"
/>
<p className="text-muted-foreground text-xs">
Ihre E-Mail-Adresse wurde vom Verein vorgegeben.
</p>
<Input type="email" value={invitation.email} readOnly className="bg-muted" />
<p className="text-xs text-muted-foreground">Ihre E-Mail-Adresse wurde vom Verein vorgegeben.</p>
</div>
<div className="space-y-2">
<Label>Passwort festlegen *</Label>
<Input
type="password"
name="password"
placeholder="Mindestens 8 Zeichen"
required
minLength={8}
/>
<Input type="password" name="password" placeholder="Mindestens 8 Zeichen" required minLength={8} />
</div>
<div className="space-y-2">
<Label>Passwort wiederholen *</Label>
<Input
type="password"
name="passwordConfirm"
placeholder="Passwort bestätigen"
required
minLength={8}
/>
<Input type="password" name="passwordConfirm" placeholder="Passwort bestätigen" required minLength={8} />
</div>
<Button type="submit" className="w-full">
@@ -155,14 +115,8 @@ export default async function PortalInvitePage({
</Button>
</form>
<p className="text-muted-foreground mt-4 text-center text-xs">
Bereits ein Konto?{' '}
<Link
href={`/club/${slug}/portal`}
className="text-primary underline"
>
Anmelden
</Link>
<p className="mt-4 text-xs text-center text-muted-foreground">
Bereits ein Konto? <Link href={`/club/${slug}/portal`} className="text-primary underline">Anmelden</Link>
</p>
</CardContent>
</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 { createClient } from '@supabase/supabase-js';
import { UserCircle, FileText, CreditCard, Shield } from 'lucide-react';
import { PortalLoginForm } from '@kit/site-builder/components';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
interface Props {
params: Promise<{ slug: string }>;
@@ -20,23 +18,15 @@ export default async function MemberPortalPage({ params }: Props) {
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
);
const { data: account } = await supabase
.from('accounts')
.select('id, name')
.eq('slug', slug)
.single();
if (!account)
return <div className="p-8 text-center">Organisation nicht gefunden</div>;
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
if (!account) return <div className="p-8 text-center">Organisation nicht gefunden</div>;
// Check if user is already logged in
const {
data: { user },
} = await supabase.auth.getUser();
const { data: { user } } = await supabase.auth.getUser();
if (user) {
// Check if this user is a member of this club
const { data: member } = await supabase
.from('members')
const { data: member } = await supabase.from('members')
.select('id, first_name, last_name, status')
.eq('account_id', account.id)
.eq('user_id', user.id)
@@ -45,61 +35,45 @@ export default async function MemberPortalPage({ params }: Props) {
if (member) {
// Logged in member — show portal dashboard
return (
<div className="bg-muted/30 min-h-screen">
<header className="bg-background border-b px-6 py-4">
<div className="mx-auto flex max-w-4xl items-center justify-between">
<div className="min-h-screen bg-muted/30">
<header className="border-b bg-background px-6 py-4">
<div className="flex items-center justify-between max-w-4xl mx-auto">
<div className="flex items-center gap-3">
<Shield className="text-primary h-5 w-5" />
<h1 className="text-lg font-bold">
Mitgliederbereich {String(account.name)}
</h1>
<Shield className="h-5 w-5 text-primary" />
<h1 className="text-lg font-bold">Mitgliederbereich {String(account.name)}</h1>
</div>
<div className="flex items-center gap-3">
<span className="text-muted-foreground text-sm">
{String(member.first_name)} {String(member.last_name)}
</span>
<Link href={`/club/${slug}`}>
<Button variant="ghost" size="sm">
Website
</Button>
</Link>
<span className="text-sm text-muted-foreground">{String(member.first_name)} {String(member.last_name)}</span>
<Link href={`/club/${slug}`}><Button variant="ghost" size="sm"> Website</Button></Link>
</div>
</div>
</header>
<main className="mx-auto max-w-4xl px-6 py-12">
<h2 className="mb-6 text-2xl font-bold">
Willkommen, {String(member.first_name)}!
</h2>
<main className="max-w-4xl mx-auto py-12 px-6">
<h2 className="text-2xl font-bold mb-6">Willkommen, {String(member.first_name)}!</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<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">
<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>
<p className="text-muted-foreground mt-1 text-xs">
Kontaktdaten und Datenschutz
</p>
<p className="text-xs text-muted-foreground mt-1">Kontaktdaten und Datenschutz</p>
</CardContent>
</Card>
</Link>
<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">
<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>
<p className="text-muted-foreground mt-1 text-xs">
Rechnungen und Bescheinigungen
</p>
<p className="text-xs text-muted-foreground mt-1">Rechnungen und Bescheinigungen</p>
</CardContent>
</Card>
</Link>
<Card>
<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>
<p className="text-muted-foreground mt-1 text-xs">
Digital anzeigen
</p>
<p className="text-xs text-muted-foreground mt-1">Digital anzeigen</p>
</CardContent>
</Card>
</div>
@@ -111,18 +85,14 @@ export default async function MemberPortalPage({ params }: Props) {
// Not logged in or not a member — show login form
return (
<div className="bg-muted/30 min-h-screen">
<header className="bg-background border-b px-6 py-4">
<div className="mx-auto flex max-w-4xl items-center justify-between">
<div className="min-h-screen bg-muted/30">
<header className="border-b bg-background px-6 py-4">
<div className="flex items-center justify-between max-w-4xl mx-auto">
<h1 className="text-lg font-bold">Mitgliederbereich</h1>
<Link href={`/club/${slug}`}>
<Button variant="ghost" size="sm">
Zurück zur Website
</Button>
</Link>
<Link href={`/club/${slug}`}><Button variant="ghost" size="sm"> Zurück zur Website</Button></Link>
</div>
</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)} />
</main>
</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 {
UserCircle,
Mail,
MapPin,
Phone,
Shield,
Calendar,
Link2,
} from 'lucide-react';
import { formatDate } from '@kit/shared/dates';
import { Button } from '@kit/ui/button';
import { redirect } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { PortalLinkedAccounts } from './_components/portal-linked-accounts';
import { UserCircle, Mail, MapPin, Phone, Shield, Calendar } from 'lucide-react';
import Link from 'next/link';
interface Props {
params: Promise<{ slug: string }>;
@@ -33,23 +19,15 @@ export default async function PortalProfilePage({ params }: Props) {
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
);
const { data: account } = await supabase
.from('accounts')
.select('id, name')
.eq('slug', slug)
.single();
if (!account)
return <div className="p-8 text-center">Organisation nicht gefunden</div>;
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
if (!account) return <div className="p-8 text-center">Organisation nicht gefunden</div>;
// Get current user
const {
data: { user },
} = await supabase.auth.getUser();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect(`/club/${slug}/portal`);
// Find member linked to this user
const { data: member } = await supabase
.from('members')
const { data: member } = await supabase.from('members')
.select('*')
.eq('account_id', account.id)
.eq('user_id', user.id)
@@ -57,20 +35,17 @@ export default async function PortalProfilePage({ params }: Props) {
if (!member) {
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">
<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>
<p className="text-muted-foreground mt-2 text-sm">
Ihr Benutzerkonto ist nicht mit einem Mitgliedsprofil in diesem
Verein verknüpft. Bitte wenden Sie sich an Ihren
Vereinsadministrator.
<p className="text-sm text-muted-foreground mt-2">
Ihr Benutzerkonto ist nicht mit einem Mitgliedsprofil in diesem Verein verknüpft.
Bitte wenden Sie sich an Ihren Vereinsadministrator.
</p>
<Link href={`/club/${slug}/portal`}>
<Button variant="outline" className="mt-4">
Zurück
</Button>
<Button variant="outline" className="mt-4"> Zurück</Button>
</Link>
</CardContent>
</Card>
@@ -81,35 +56,28 @@ export default async function PortalProfilePage({ params }: Props) {
const m = member;
return (
<div className="bg-muted/30 min-h-screen">
<header className="bg-background border-b px-6 py-4">
<div className="mx-auto flex max-w-4xl items-center justify-between">
<div className="min-h-screen bg-muted/30">
<header className="border-b bg-background px-6 py-4">
<div className="flex items-center justify-between max-w-4xl mx-auto">
<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>
</div>
<Link href={`/club/${slug}/portal`}>
<Button variant="ghost" size="sm">
Zurück zum Portal
</Button>
</Link>
<Link href={`/club/${slug}/portal`}><Button variant="ghost" size="sm"> Zurück zum Portal</Button></Link>
</div>
</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>
<CardContent className="p-6">
<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" />
</div>
<div>
<h2 className="text-xl font-bold">
{String(m.first_name)} {String(m.last_name)}
</h2>
<p className="text-muted-foreground text-sm">
Nr. {String(m.member_number ?? '—')} Mitglied seit{' '}
{formatDate(m.entry_date)}
<h2 className="text-xl font-bold">{String(m.first_name)} {String(m.last_name)}</h2>
<p className="text-sm text-muted-foreground">
Nr. {String(m.member_number ?? '—')} Mitglied seit {m.entry_date ? new Date(String(m.entry_date)).toLocaleDateString('de-DE') : '—'}
</p>
</div>
</div>
@@ -117,111 +85,37 @@ export default async function PortalProfilePage({ params }: Props) {
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-4 w-4" />
Kontaktdaten
</CardTitle>
</CardHeader>
<CardHeader><CardTitle className="flex items-center gap-2"><Mail className="h-4 w-4" />Kontaktdaten</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Vorname</Label>
<Input defaultValue={String(m.first_name)} readOnly />
</div>
<div className="space-y-2">
<Label>Nachname</Label>
<Input defaultValue={String(m.last_name)} readOnly />
</div>
<div className="space-y-2">
<Label>E-Mail</Label>
<Input defaultValue={String(m.email ?? '')} />
</div>
<div className="space-y-2">
<Label>Telefon</Label>
<Input defaultValue={String(m.phone ?? '')} />
</div>
<div className="space-y-2">
<Label>Mobil</Label>
<Input defaultValue={String(m.mobile ?? '')} />
</div>
<div className="space-y-2"><Label>Vorname</Label><Input defaultValue={String(m.first_name)} readOnly /></div>
<div className="space-y-2"><Label>Nachname</Label><Input defaultValue={String(m.last_name)} readOnly /></div>
<div className="space-y-2"><Label>E-Mail</Label><Input defaultValue={String(m.email ?? '')} /></div>
<div className="space-y-2"><Label>Telefon</Label><Input defaultValue={String(m.phone ?? '')} /></div>
<div className="space-y-2"><Label>Mobil</Label><Input defaultValue={String(m.mobile ?? '')} /></div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
Adresse
</CardTitle>
</CardHeader>
<CardHeader><CardTitle className="flex items-center gap-2"><MapPin className="h-4 w-4" />Adresse</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Straße</Label>
<Input defaultValue={String(m.street ?? '')} />
</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>
<div className="space-y-2"><Label>Straße</Label><Input defaultValue={String(m.street ?? '')} /></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>
</Card>
<Card>
<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>
<CardHeader><CardTitle className="flex items-center gap-2"><Shield className="h-4 w-4" />Datenschutz-Einwilligungen</CardTitle></CardHeader>
<CardContent className="space-y-3">
{[
{
key: 'gdpr_newsletter',
label: 'Newsletter per E-Mail',
value: m.gdpr_newsletter,
},
{
key: 'gdpr_internet',
label: 'Veröffentlichung auf der Homepage',
value: m.gdpr_internet,
},
{
key: 'gdpr_print',
label: 'Veröffentlichung in der Vereinszeitung',
value: m.gdpr_print,
},
{
key: 'gdpr_birthday_info',
label: 'Geburtstagsinfo an Mitglieder',
value: m.gdpr_birthday_info,
},
{ key: 'gdpr_newsletter', label: 'Newsletter per E-Mail', value: m.gdpr_newsletter },
{ key: 'gdpr_internet', label: 'Veröffentlichung auf der Homepage', value: m.gdpr_internet },
{ key: 'gdpr_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 }) => (
<label key={key} className="flex items-center gap-3 text-sm">
<input
type="checkbox"
defaultChecked={Boolean(value)}
className="border-input h-4 w-4 rounded"
/>
<input type="checkbox" defaultChecked={Boolean(value)} className="h-4 w-4 rounded border-input" />
{label}
</label>
))}

View File

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

View File

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

View File

@@ -1,15 +1,12 @@
'use client';
import Link from 'next/link';
import { LogOut, Menu } from 'lucide-react';
import { Menu } from 'lucide-react';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
@@ -17,6 +14,10 @@ import {
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import {
MobileNavRouteLinks,
MobileNavSignOutItem,
} from '~/components/mobile-navigation-shared';
import featuresFlagConfig from '~/config/feature-flags.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 }) {
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 (
<DropdownMenu>
<DropdownMenuTrigger>
@@ -60,6 +42,7 @@ export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
</DropdownMenuLabel>
<HomeAccountSelector
collapsed={false}
userId={props.workspace.user.id}
accounts={props.workspace.accounts}
/>
@@ -68,57 +51,16 @@ export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
<DropdownMenuSeparator />
</If>
<DropdownMenuGroup>{Links}</DropdownMenuGroup>
<DropdownMenuGroup>
<MobileNavRouteLinks
routes={personalAccountNavigationConfig.routes}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
<MobileNavSignOutItem 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 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';
export function HomeLayoutPageHeader(
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
export async function HomeLayoutPageHeader(
props: React.PropsWithChildren<{
title: 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 (
<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 { redirect } from 'next/navigation';
import { SupabaseClient } from '@supabase/supabase-js';
import * as z from 'zod';
@@ -81,9 +83,12 @@ class UserBillingService {
`User requested a personal account checkout session. Contacting provider...`,
);
let checkoutToken: string | null | undefined;
let url: string | null | undefined;
try {
// call the payment gateway to create the checkout session
const { checkoutToken } = await service.createCheckoutSession({
const checkout = await service.createCheckoutSession({
returnUrl,
accountId,
customerEmail: user.email,
@@ -93,32 +98,55 @@ class UserBillingService {
enableDiscountField: product.enableDiscountField,
});
logger.info(
{
userId: user.id,
},
`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 {
checkoutToken,
};
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,
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(
{
userId: user.id,
},
`Checkout session created. Returning checkout token to client...`,
);
return {
checkoutToken,
};
}
/**

View File

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

View File

@@ -1,9 +1,11 @@
'use client';
import { useContext } from 'react';
import { useRouter } from 'next/navigation';
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 pathsConfig from '~/config/paths.config';
@@ -23,7 +25,7 @@ export function TeamAccountAccountsSelector(params: {
}>;
}) {
const router = useRouter();
const ctx = useSidebar();
const ctx = useContext(SidebarContext);
return (
<AccountSelector

View File

@@ -1,32 +1,28 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Home, LogOut, Menu } from 'lucide-react';
import * as z from 'zod';
import { Menu } from 'lucide-react';
import { AccountSelector } from '@kit/accounts/account-selector';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
import { Trans } from '@kit/ui/trans';
import {
MobileNavRouteLinks,
MobileNavSignOutItem,
} from '~/components/mobile-navigation-shared';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
type Accounts = Array<{
label: string | null;
@@ -35,7 +31,6 @@ type Accounts = Array<{
}>;
const features = {
enableTeamAccounts: featureFlagsConfig.enableTeamAccounts,
enableTeamCreation: featureFlagsConfig.enableTeamCreation,
};
@@ -44,129 +39,23 @@ export const TeamAccountLayoutMobileNavigation = (
account: string;
userId: string;
accounts: Accounts;
config: z.output<typeof NavigationConfigSchema>;
}>,
) => {
const router = useRouter();
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 (
<DropdownMenu>
<DropdownMenuTrigger>
<Menu className={'h-9'} />
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
<TeamAccountsModal
userId={props.userId}
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'} />
</span>
</DropdownMenuItem>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>
<DropdownMenuContent className={'w-screen rounded-none'}>
<DropdownMenuGroup>
<DropdownMenuLabel>
<Trans i18nKey={'common.yourAccounts'} />
</DialogTitle>
</DialogHeader>
</DropdownMenuLabel>
<div className={'py-6'}>
<AccountSelector
className={'w-full max-w-full'}
userId={props.userId}
@@ -185,8 +74,20 @@ function TeamAccountsModal(props: {
router.replace(path);
}}
/>
</div>
</DialogContent>
</Dialog>
</DropdownMenuGroup>
<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';
export function TeamAccountLayoutPageHeader(
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
export async function TeamAccountLayoutPageHeader(
props: React.PropsWithChildren<{
title: string | React.ReactNode;
description: string | React.ReactNode;
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 (
<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 (
<div className={'flex w-full flex-1 justify-between'}>
<div className={'flex items-center space-x-8'}>
<AppLogo />
<div>
<AppLogo />
</div>
<BorderedNavigationMenu>
{routes.map((route) => (
@@ -53,20 +55,22 @@ export function TeamAccountNavigationMenu(props: {
</BorderedNavigationMenu>
</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}>
<TeamAccountNotifications accountId={account.id} userId={user.id} />
</If>
<TeamAccountAccountsSelector
userId={user.id}
selectedAccount={account.slug}
accounts={accounts.map((account) => ({
label: account.name,
value: account.slug,
image: account.picture_url,
}))}
/>
<div>
<TeamAccountAccountsSelector
userId={user.id}
selectedAccount={account.slug}
accounts={accounts.map((account) => ({
label: account.name,
value: account.slug,
image: account.picture_url,
}))}
/>
</div>
<div>
<ProfileAccountDropdownContainer

View File

@@ -1,4 +1,6 @@
import 'server-only';
import { redirect } from 'next/navigation';
import { SupabaseClient } from '@supabase/supabase-js';
import * as z from 'zod';
@@ -106,9 +108,12 @@ class TeamBillingService {
`Creating checkout session...`,
);
let checkoutToken: string | null = null;
let url: string | null | undefined;
try {
// call the payment gateway to create the checkout session
const { checkoutToken } = await service.createCheckoutSession({
const checkout = await service.createCheckoutSession({
accountId,
plan,
returnUrl,
@@ -118,22 +123,37 @@ class TeamBillingService {
enableDiscountField: product.enableDiscountField,
});
// return the checkout token to the client
// so we can call the payment gateway to complete the checkout
return {
checkoutToken,
};
checkoutToken = checkout.checkoutToken;
url = checkout.url;
} catch (error) {
const message = Error.isError(error) ? error.message : error;
logger.error(
{
...ctx,
error,
error: message,
},
`Error creating the checkout session`,
);
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,
} from 'lucide-react';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
@@ -22,8 +21,8 @@ import {
CardTitle,
} from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps {
params: Promise<{ account: string; bookingId: string }>;
@@ -125,7 +124,9 @@ export default async function BookingDetailPage({ params }: PageProps) {
{STATUS_LABEL[status] ?? status}
</Badge>
</div>
<p className="text-muted-foreground text-sm">ID: {bookingId}</p>
<p className="text-muted-foreground text-sm">
ID: {bookingId}
</p>
</div>
</div>
</div>
@@ -143,7 +144,7 @@ export default async function BookingDetailPage({ params }: PageProps) {
{room ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground text-sm">
<span className="text-sm text-muted-foreground">
Zimmernummer
</span>
<span className="font-medium">
@@ -152,14 +153,14 @@ export default async function BookingDetailPage({ params }: PageProps) {
</div>
{room.name && (
<div className="flex justify-between">
<span className="text-muted-foreground text-sm">
<span className="text-sm text-muted-foreground">
Name
</span>
<span className="font-medium">{String(room.name)}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground text-sm">Typ</span>
<span className="text-sm text-muted-foreground">Typ</span>
<span className="font-medium">
{String(room.room_type ?? '—')}
</span>
@@ -185,25 +186,29 @@ export default async function BookingDetailPage({ params }: PageProps) {
{guest ? (
<div className="space-y-2">
<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">
{String(guest.first_name)} {String(guest.last_name)}
</span>
</div>
{guest.email && (
<div className="flex justify-between">
<span className="text-muted-foreground text-sm">
<span className="text-sm text-muted-foreground">
E-Mail
</span>
<span className="font-medium">{String(guest.email)}</span>
<span className="font-medium">
{String(guest.email)}
</span>
</div>
)}
{guest.phone && (
<div className="flex justify-between">
<span className="text-muted-foreground text-sm">
<span className="text-sm text-muted-foreground">
Telefon
</span>
<span className="font-medium">{String(guest.phone)}</span>
<span className="font-medium">
{String(guest.phone)}
</span>
</div>
)}
</div>
@@ -226,30 +231,56 @@ export default async function BookingDetailPage({ params }: PageProps) {
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground text-sm">
<span className="text-sm text-muted-foreground">
Check-in
</span>
<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>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground text-sm">
<span className="text-sm text-muted-foreground">
Check-out
</span>
<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>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground text-sm">
<span className="text-sm text-muted-foreground">
Erwachsene
</span>
<span className="font-medium">{booking.adults ?? '—'}</span>
<span className="font-medium">
{booking.adults ?? '—'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground text-sm">Kinder</span>
<span className="font-medium">{booking.children ?? 0}</span>
<span className="text-sm text-muted-foreground">
Kinder
</span>
<span className="font-medium">
{booking.children ?? 0}
</span>
</div>
</div>
</CardContent>
@@ -263,7 +294,7 @@ export default async function BookingDetailPage({ params }: PageProps) {
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground text-sm">
<span className="text-sm text-muted-foreground">
Gesamtpreis
</span>
<span className="text-2xl font-bold">
@@ -274,7 +305,7 @@ export default async function BookingDetailPage({ params }: PageProps) {
</div>
{booking.notes && (
<div className="border-t pt-2">
<span className="text-muted-foreground text-sm">
<span className="text-sm text-muted-foreground">
Notizen
</span>
<p className="mt-1 text-sm">{String(booking.notes)}</p>
@@ -289,7 +320,9 @@ export default async function BookingDetailPage({ params }: PageProps) {
<Card>
<CardHeader>
<CardTitle>Aktionen</CardTitle>
<CardDescription>Status der Buchung ändern</CardDescription>
<CardDescription>
Status der Buchung ändern
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
@@ -317,10 +350,10 @@ export default async function BookingDetailPage({ params }: PageProps) {
)}
{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{' '}
{status === 'cancelled' ? 'storniert' : 'abgeschlossen'}
keine weiteren Aktionen verfügbar.
{status === 'cancelled' ? 'storniert' : 'abgeschlossen'} keine
weiteren Aktionen verfügbar.
</p>
) : null}
</div>

View File

@@ -2,14 +2,15 @@ import Link from 'next/link';
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps {
params: Promise<{ account: string }>;
@@ -42,11 +43,7 @@ function getFirstWeekday(year: number, month: number): number {
return day === 0 ? 6 : day - 1;
}
function isDateInRange(
date: string,
checkIn: string,
checkOut: string,
): boolean {
function isDateInRange(date: string, checkIn: string, checkOut: string): boolean {
return date >= checkIn && date < checkOut;
}
@@ -104,11 +101,7 @@ export default async function BookingCalendarPage({ params }: PageProps) {
}
// Build calendar grid cells
const cells: Array<{
day: number | null;
occupied: boolean;
isToday: boolean;
}> = [];
const cells: Array<{ day: number | null; occupied: boolean; isToday: boolean }> = [];
// Empty cells before first day
for (let i = 0; i < firstWeekday; i++) {
@@ -165,11 +158,11 @@ export default async function BookingCalendarPage({ params }: PageProps) {
</CardHeader>
<CardContent>
{/* 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) => (
<div
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}
</div>
@@ -187,13 +180,13 @@ export default async function BookingCalendarPage({ params }: PageProps) {
: cell.occupied
? 'bg-primary/15 text-primary font-semibold'
: '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 && (
<>
<span>{cell.day}</span>
{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>
{/* 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">
<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
</div>
<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
</div>
<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
</div>
</div>
@@ -224,7 +217,7 @@ export default async function BookingCalendarPage({ params }: PageProps) {
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-muted-foreground text-sm">
<p className="text-sm text-muted-foreground">
Buchungen in diesem Monat
</p>
<p className="text-2xl font-bold">{bookings.data.length}</p>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -68,13 +68,12 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
<select
id="documentType"
name="documentType"
data-test="document-type-select"
value={selectedType}
onChange={(e) => {
setSelectedType(e.target.value);
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="invoice">Rechnung</option>
@@ -93,8 +92,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
<p className="font-medium">Demnächst verfügbar</p>
<p className="mt-1 text-amber-700 dark:text-amber-300">
Die Generierung von &ldquo;{DOCUMENT_LABELS[selectedType]}&rdquo;
befindet sich noch in Entwicklung und wird in Kürze verfügbar
sein.
befindet sich noch in Entwicklung und wird in Kürze verfügbar sein.
</p>
</div>
</div>
@@ -120,7 +118,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
id="format"
name="format"
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="A5">A5</option>
@@ -133,7 +131,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
id="orientation"
name="orientation"
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="landscape">Querformat</option>
@@ -142,7 +140,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
</div>
{/* 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>
<strong>Hinweis:</strong>{' '}
{selectedType === 'member-card'
@@ -191,11 +189,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
{/* Submit button */}
<div className="flex justify-end">
<Button
type="submit"
data-test="document-generate-btn"
disabled={isPending || isComingSoon}
>
<Button type="submit" disabled={isPending || isComingSoon}>
{isPending ? (
<>
<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.
* 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 byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,18 @@
import Link from 'next/link';
import { CalendarDays, ClipboardList, Users } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createEventManagementApi } from '@kit/event-management/api';
import { formatDate } from '@kit/shared/dates';
import { getTranslations } from 'next-intl/server';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
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 { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
import { EVENT_STATUS_VARIANT, EVENT_STATUS_LABEL } from '~/lib/status-badges';
interface PageProps {
@@ -64,7 +64,9 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
{/* Header */}
<div>
<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>
{/* Stats */}
@@ -106,25 +108,17 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">
{t('event')}
</th>
<th className="p-3 text-left font-medium">
{t('eventDate')}
</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-left font-medium">{t('eventDate')}</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">
{t('registrations')}
</th>
<th className="p-3 text-right font-medium">
{t('utilization')}
</th>
<th className="p-3 text-right font-medium">{t('utilization')}</th>
</tr>
</thead>
<tbody>
@@ -139,7 +133,7 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
return (
<tr
key={event.id}
className="hover:bg-muted/30 border-b"
className="border-b hover:bg-muted/30"
>
<td className="p-3 font-medium">
<Link
@@ -149,12 +143,17 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
{event.name}
</Link>
</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">
<Badge
variant={
EVENT_STATUS_VARIANT[event.status] ??
'secondary'
EVENT_STATUS_VARIANT[event.status] ?? 'secondary'
}
>
{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 { createFinanceApi } from '@kit/finance/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
import { createFinanceApi } from '@kit/finance/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps {
params: Promise<{ account: string; id: string }>;
@@ -55,7 +55,7 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
const api = createFinanceApi(client);
const invoice = await api.getInvoiceWithItems(id);
if (!invoice) return <AccountNotFound />;
if (!invoice) return <div>Rechnung nicht gefunden</div>;
const status = String(invoice.status);
const items = (invoice.items ?? []) as Array<Record<string, unknown>>;
@@ -87,7 +87,7 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
<CardContent>
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<dt className="text-muted-foreground text-sm font-medium">
<dt className="text-sm font-medium text-muted-foreground">
Empfänger
</dt>
<dd className="mt-1 text-sm font-semibold">
@@ -95,23 +95,31 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium">
<dt className="text-sm font-medium text-muted-foreground">
Rechnungsdatum
</dt>
<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>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium">
<dt className="text-sm font-medium text-muted-foreground">
Fälligkeitsdatum
</dt>
<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>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium">
<dt className="text-sm font-medium text-muted-foreground">
Gesamtbetrag
</dt>
<dd className="mt-1 text-sm font-semibold">
@@ -147,14 +155,14 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
</CardHeader>
<CardContent>
{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.
</p>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">
Beschreibung
</th>
@@ -169,7 +177,7 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
{items.map((item) => (
<tr
key={String(item.id)}
className="hover:bg-muted/30 border-b"
className="border-b hover:bg-muted/30"
>
<td className="p-3">
{String(item.description ?? '—')}
@@ -191,7 +199,7 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
))}
</tbody>
<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">
Zwischensumme
</td>

View File

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

View File

@@ -2,16 +2,16 @@ import Link from 'next/link';
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 { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
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 { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
import {
INVOICE_STATUS_VARIANT,
INVOICE_STATUS_LABEL,
@@ -77,7 +77,7 @@ export default async function InvoicesPage({ params }: PageProps) {
<div className="rounded-md border">
<table className="w-full text-sm">
<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">Empfänger</th>
<th className="p-3 text-left font-medium">Datum</th>
@@ -92,7 +92,7 @@ export default async function InvoicesPage({ params }: PageProps) {
return (
<tr
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">
<Link
@@ -106,10 +106,18 @@ export default async function InvoicesPage({ params }: PageProps) {
{String(invoice.recipient_name ?? '—')}
</td>
<td className="p-3">
{formatDate(invoice.issue_date)}
{invoice.issue_date
? new Date(
String(invoice.issue_date),
).toLocaleDateString('de-DE')
: '—'}
</td>
<td className="p-3">
{formatDate(invoice.due_date)}
{invoice.due_date
? new Date(
String(invoice.due_date),
).toLocaleDateString('de-DE')
: '—'}
</td>
<td className="p-3 text-right">
{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 { createFinanceApi } from '@kit/finance/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
import { createFinanceApi } from '@kit/finance/api';
import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
import {
BATCH_STATUS_VARIANT,
BATCH_STATUS_LABEL,
@@ -61,7 +61,9 @@ export default async function FinancePage({ params }: PageProps) {
<div className="flex items-center justify-between">
<div>
<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 className="flex gap-2">
<Link href={`/home/${account}/finance/invoices/new`}>
@@ -122,7 +124,7 @@ export default async function FinancePage({ params }: PageProps) {
<div className="rounded-md border">
<table className="w-full text-sm">
<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">Typ</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>) => (
<tr
key={String(batch.id)}
className="hover:bg-muted/30 border-b"
className="border-b hover:bg-muted/30"
>
<td className="p-3">
<Badge
variant={
BATCH_STATUS_VARIANT[String(batch.status)] ??
'secondary'
BATCH_STATUS_VARIANT[String(batch.status)] ?? 'secondary'
}
>
{BATCH_STATUS_LABEL[String(batch.status)] ??
String(batch.status)}
{BATCH_STATUS_LABEL[String(batch.status)] ?? String(batch.status)}
</Badge>
</td>
<td className="p-3">
@@ -157,7 +157,11 @@ export default async function FinancePage({ params }: PageProps) {
: '—'}
</td>
<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>
</tr>
))}
@@ -192,7 +196,7 @@ export default async function FinancePage({ params }: PageProps) {
<div className="rounded-md border">
<table className="w-full text-sm">
<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">Empfänger</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>) => (
<tr
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">
<Link

View File

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

View File

@@ -2,15 +2,15 @@ import Link from 'next/link';
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 { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
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 { AccountNotFound } from '~/components/account-not-found';
interface PageProps {
params: Promise<{ account: string; batchId: string }>;
@@ -74,7 +74,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
api.getBatchItems(batchId),
]);
if (!batch) return <AccountNotFound />;
if (!batch) return <div>Einzug nicht gefunden</div>;
const status = String(batch.status);
@@ -95,7 +95,9 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
{/* Summary Card */}
<Card>
<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'}>
{STATUS_LABEL[status] ?? status}
</Badge>
@@ -103,7 +105,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
<CardContent>
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<dt className="text-muted-foreground text-sm font-medium">
<dt className="text-sm font-medium text-muted-foreground">
Typ
</dt>
<dd className="mt-1 text-sm font-semibold">
@@ -113,7 +115,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium">
<dt className="text-sm font-medium text-muted-foreground">
Betrag
</dt>
<dd className="mt-1 text-sm font-semibold">
@@ -123,7 +125,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium">
<dt className="text-sm font-medium text-muted-foreground">
Anzahl
</dt>
<dd className="mt-1 text-sm font-semibold">
@@ -131,11 +133,15 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium">
<dt className="text-sm font-medium text-muted-foreground">
Ausführungsdatum
</dt>
<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>
</div>
</dl>
@@ -156,14 +162,14 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
</CardHeader>
<CardContent>
{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.
</p>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<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">IBAN</th>
<th className="p-3 text-right font-medium">Betrag</th>
@@ -176,7 +182,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
return (
<tr
key={String(item.id)}
className="hover:bg-muted/30 border-b"
className="border-b hover:bg-muted/30"
>
<td className="p-3 font-medium">
{String(item.debtor_name ?? '—')}

View File

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

View File

@@ -2,17 +2,20 @@ import Link from 'next/link';
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 { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
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 { 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 {
params: Promise<{ account: string }>;
@@ -76,7 +79,7 @@ export default async function SepaPage({ params }: PageProps) {
<div className="rounded-md border">
<table className="w-full text-sm">
<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">Typ</th>
<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>) => (
<tr
key={String(batch.id)}
className="hover:bg-muted/30 border-b"
className="border-b hover:bg-muted/30"
>
<td className="p-3">
<Badge
variant={
BATCH_STATUS_VARIANT[String(batch.status)] ??
'secondary'
BATCH_STATUS_VARIANT[String(batch.status)] ?? 'secondary'
}
>
{BATCH_STATUS_LABEL[String(batch.status)] ??
@@ -128,7 +130,11 @@ export default async function SepaPage({ params }: PageProps) {
{String(batch.item_count ?? 0)}
</td>
<td className="p-3">
{formatDate(batch.execution_date)}
{batch.execution_date
? new Date(
String(batch.execution_date),
).toLocaleDateString('de-DE')
: '—'}
</td>
</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 { 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 { AccountNotFound } from '~/components/account-not-found';
interface Props {
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 { 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 { AccountNotFound } from '~/components/account-not-found';
interface Props {
params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function CompetitionsPage({
params,
searchParams,
}: Props) {
export default async function CompetitionsPage({ params, searchParams }: Props) {
const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,21 +2,19 @@ import Link from 'next/link';
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 { Button } from '@kit/ui/button';
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 { MEETING_TYPE_LABELS } from '@kit/sitzungsprotokolle/lib/meetings-constants';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps {
params: Promise<{ account: string; protocolId: string }>;
}
@@ -41,12 +39,9 @@ export default async function ProtocolDetailPage({ params }: PageProps) {
} catch {
return (
<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>
<Link
href={`/home/${account}/meetings/protocols`}
className="mt-4 inline-block"
>
<Link href={`/home/${account}/meetings/protocols`} className="mt-4 inline-block">
<Button variant="outline">Zurück zur Übersicht</Button>
</Link>
</div>
@@ -77,12 +72,18 @@ export default async function ProtocolDetailPage({ params }: PageProps) {
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-xl">{protocol.title}</CardTitle>
<div className="text-muted-foreground mt-2 flex flex-wrap items-center gap-2 text-sm">
<span>{formatDateFull(protocol.meeting_date)}</span>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>
{new Date(protocol.meeting_date).toLocaleDateString('de-DE', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
<span>·</span>
<Badge variant="secondary">
{MEETING_TYPE_LABELS[protocol.meeting_type] ??
protocol.meeting_type}
{MEETING_TYPE_LABELS[protocol.meeting_type] ?? protocol.meeting_type}
</Badge>
{protocol.is_published ? (
<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">
{protocol.location && (
<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>
</div>
)}
{protocol.attendees && (
<div className="sm:col-span-2">
<p className="text-muted-foreground text-sm font-medium">
Teilnehmer
</p>
<p className="text-sm whitespace-pre-line">
{protocol.attendees}
</p>
<p className="text-sm font-medium text-muted-foreground">Teilnehmer</p>
<p className="text-sm whitespace-pre-line">{protocol.attendees}</p>
</div>
)}
{protocol.remarks && (
<div className="sm:col-span-2">
<p className="text-muted-foreground text-sm font-medium">
Anmerkungen
</p>
<p className="text-sm whitespace-pre-line">
{protocol.remarks}
</p>
<p className="text-sm font-medium text-muted-foreground">Anmerkungen</p>
<p className="text-sm whitespace-pre-line">{protocol.remarks}</p>
</div>
)}
</CardContent>

View File

@@ -1,11 +1,8 @@
import {
MeetingsTabNavigation,
CreateProtocolForm,
} from '@kit/sitzungsprotokolle/components';
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 { AccountNotFound } from '~/components/account-not-found';
interface PageProps {
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 { 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 { AccountNotFound } from '~/components/account-not-found';
interface PageProps {
params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function ProtocolsPage({
params,
searchParams,
}: PageProps) {
export default async function ProtocolsPage({ params, searchParams }: PageProps) {
const { account } = await params;
const sp = await searchParams;
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 { 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 { AccountNotFound } from '~/components/account-not-found';
interface PageProps {
params: Promise<{ account: string }>;
@@ -39,8 +36,7 @@ export default async function TasksPage({ params, searchParams }: PageProps) {
<div>
<h1 className="text-2xl font-bold">Offene Aufgaben</h1>
<p className="text-muted-foreground">
Alle offenen und in Bearbeitung befindlichen Tagesordnungspunkte
über alle Protokolle.
Alle offenen und in Bearbeitung befindlichen Tagesordnungspunkte über alle Protokolle.
</p>
</div>
<OpenTasksView

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,12 @@
import Link from 'next/link';
import { Pencil, Trash2, Lock, Unlock } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createModuleBuilderApi } from '@kit/module-builder/api';
import { ModuleForm } from '@kit/module-builder/components';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Badge } from '@kit/ui/badge';
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';
@@ -16,9 +14,7 @@ interface RecordDetailPageProps {
params: Promise<{ account: string; moduleId: string; recordId: string }>;
}
export default async function RecordDetailPage({
params,
}: RecordDetailPageProps) {
export default async function RecordDetailPage({ params }: RecordDetailPageProps) {
const { account, moduleId, recordId } = await params;
const client = getSupabaseServerClient();
const api = createModuleBuilderApi(client);
@@ -30,49 +26,29 @@ export default async function RecordDetailPage({
if (!moduleWithFields || !record) return <div>Nicht gefunden</div>;
const fields = (
moduleWithFields as unknown as {
fields: Array<{
name: string;
display_name: string;
field_type: string;
is_required: boolean;
placeholder: string | null;
help_text: string | null;
is_readonly: boolean;
select_options: Array<{ label: string; value: string }> | null;
section: string;
sort_order: number;
show_in_form: boolean;
width: string;
}>;
}
).fields;
const fields = (moduleWithFields as unknown as {
fields: Array<{
name: string; display_name: string; field_type: string;
is_required: boolean; placeholder: string | null;
help_text: string | null; is_readonly: boolean;
select_options: Array<{ label: string; value: string }> | null;
section: string; sort_order: number; show_in_form: boolean; width: string;
}>;
}).fields;
const data = (record.data ?? {}) as Record<string, unknown>;
const isLocked = record.status === 'locked';
return (
<CmsPageShell
account={account}
title={`${String(moduleWithFields.display_name)} — Datensatz`}
>
<CmsPageShell account={account} title={`${String(moduleWithFields.display_name)} — Datensatz`}>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge
variant={
isLocked
? 'destructive'
: record.status === 'active'
? 'default'
: 'secondary'
}
>
<Badge variant={isLocked ? 'destructive' : record.status === 'active' ? 'default' : 'secondary'}>
{String(record.status)}
</Badge>
<span className="text-muted-foreground text-sm">
Erstellt: {formatDate(record.created_at)}
<span className="text-sm text-muted-foreground">
Erstellt: {new Date(record.created_at).toLocaleDateString('de-DE')}
</span>
</div>
<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 { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
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';
@@ -19,45 +19,22 @@ export default async function ImportPage({ params }: ImportPageProps) {
const moduleWithFields = await api.modules.getModuleWithFields(moduleId);
if (!moduleWithFields) return <div>Modul nicht gefunden</div>;
const fields =
(
moduleWithFields as unknown as {
fields: Array<{ name: string; display_name: string }>;
}
).fields ?? [];
const fields = (moduleWithFields as unknown as { fields: Array<{ name: string; display_name: string }> }).fields ?? [];
return (
<CmsPageShell
account={account}
title={`${String(moduleWithFields.display_name)} — Import`}
>
<CmsPageShell account={account} title={`${String(moduleWithFields.display_name)} — Import`}>
<div className="space-y-6">
{/* Step indicator */}
<div className="flex items-center justify-center gap-2">
{[
'Datei hochladen',
'Spalten zuordnen',
'Vorschau',
'Importieren',
].map((step, i) => (
{['Datei hochladen', 'Spalten zuordnen', 'Vorschau', 'Importieren'].map((step, i) => (
<div key={step} className="flex items-center gap-2">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold ${
i === 0
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold ${
i === 0 ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
}`}>
{i + 1}
</div>
<span
className={`text-sm ${i === 0 ? 'font-semibold' : 'text-muted-foreground'}`}
>
{step}
</span>
{i < 3 && (
<ArrowRight className="text-muted-foreground h-4 w-4" />
)}
<span className={`text-sm ${i === 0 ? 'font-semibold' : 'text-muted-foreground'}`}>{step}</span>
{i < 3 && <ArrowRight className="h-4 w-4 text-muted-foreground" />}
</div>
))}
</div>
@@ -72,30 +49,21 @@ export default async function ImportPage({ params }: ImportPageProps) {
</CardHeader>
<CardContent>
<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" />
<p className="text-lg font-semibold">
CSV oder Excel-Datei hierher ziehen
</p>
<p className="text-muted-foreground mt-1 text-sm">
oder klicken zum Auswählen
</p>
<Upload className="mb-4 h-10 w-10 text-muted-foreground" />
<p className="text-lg font-semibold">CSV oder Excel-Datei hierher ziehen</p>
<p className="mt-1 text-sm text-muted-foreground">oder klicken zum Auswählen</p>
<input
type="file"
accept=".csv,.xlsx,.xls"
className="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 className="mt-6">
<h4 className="mb-2 text-sm font-semibold">
Verfügbare Zielfelder:
</h4>
<h4 className="text-sm font-semibold mb-2">Verfügbare Zielfelder:</h4>
<div className="flex flex-wrap gap-1">
{fields.map((field) => (
<span
key={field.name}
className="bg-muted rounded-md px-2 py-1 text-xs"
>
<span key={field.name} className="rounded-md bg-muted px-2 py-1 text-xs">
{field.display_name}
</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 { createModuleBuilderApi } from '@kit/module-builder/api';
import { ModuleForm } from '@kit/module-builder/components';
import { CmsPageShell } from '~/components/cms-page-shell';
interface NewRecordPageProps {
@@ -16,30 +16,18 @@ export default async function NewRecordPage({ params }: NewRecordPageProps) {
const moduleWithFields = await api.modules.getModuleWithFields(moduleId);
if (!moduleWithFields) return <div>Modul nicht gefunden</div>;
const fields = (
moduleWithFields as unknown as {
fields: Array<{
name: string;
display_name: string;
field_type: string;
is_required: boolean;
placeholder: string | null;
help_text: string | null;
is_readonly: boolean;
select_options: Array<{ label: string; value: string }> | null;
section: string;
sort_order: number;
show_in_form: boolean;
width: string;
}>;
}
).fields;
const fields = (moduleWithFields as unknown as {
fields: Array<{
name: string; display_name: string; field_type: string;
is_required: boolean; placeholder: string | null;
help_text: string | null; is_readonly: boolean;
select_options: Array<{ label: string; value: string }> | null;
section: string; sort_order: number; show_in_form: boolean; width: string;
}>;
}).fields;
return (
<CmsPageShell
account={account}
title={`${String(moduleWithFields.display_name)} — Neuer Datensatz`}
>
<CmsPageShell account={account} title={`${String(moduleWithFields.display_name)} — Neuer Datensatz`}>
<div className="mx-auto max-w-3xl">
<ModuleForm
fields={fields as Parameters<typeof ModuleForm>[0]['fields']}

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