Compare commits
13 Commits
49fd6b65b9
...
f82a366a52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f82a366a52 | ||
|
|
5e1976f07b | ||
|
|
a5baaae12f | ||
|
|
c98cada7f6 | ||
|
|
2a9d543ee4 | ||
|
|
98afe6aa5f | ||
|
|
da8a43a3b0 | ||
|
|
abac22feb1 | ||
|
|
3bcc5c70a3 | ||
|
|
fd8c2cc32a | ||
|
|
d3db316a68 | ||
|
|
2f80d5cc4a | ||
|
|
59546ad6d2 |
@@ -44,6 +44,12 @@ 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
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
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
|
||||
|
||||
@@ -15,7 +15,9 @@ 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
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
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');
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
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
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
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
|
||||
|
||||
@@ -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 uppercase tracking-widest">
|
||||
<p className="text-muted-foreground text-sm font-medium tracking-widest uppercase">
|
||||
<Trans i18nKey={'marketing.trustedBy'} />
|
||||
</p>
|
||||
|
||||
@@ -89,10 +89,7 @@ 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>
|
||||
@@ -184,9 +181,7 @@ function Home() {
|
||||
</b>
|
||||
.{' '}
|
||||
<span className="text-secondary-foreground/70 block font-normal tracking-tight">
|
||||
<Trans
|
||||
i18nKey={'marketing.additionalFeaturesSubheading'}
|
||||
/>
|
||||
<Trans i18nKey={'marketing.additionalFeaturesSubheading'} />
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
@@ -256,7 +251,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 dark:text-white xl:text-5xl">
|
||||
<h2 className="text-3xl font-medium tracking-tight xl:text-5xl dark:text-white">
|
||||
<Trans i18nKey={'marketing.howItWorksHeading'} />
|
||||
</h2>
|
||||
<p className="text-secondary-foreground/70 mx-auto mt-4 max-w-2xl text-xl font-medium tracking-tight">
|
||||
@@ -316,7 +311,7 @@ function Home() {
|
||||
{/* Final CTA */}
|
||||
<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 dark:text-white xl:text-5xl">
|
||||
<h2 className="max-w-3xl text-3xl font-medium tracking-tight xl:text-5xl dark:text-white">
|
||||
<Trans i18nKey={'marketing.ctaHeading'} />
|
||||
</h2>
|
||||
<p className="text-secondary-foreground/70 max-w-2xl text-lg">
|
||||
|
||||
@@ -9,10 +9,9 @@ export default async function AdminAuditPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Alle Datenänderungen (Erstellen, Ändern, Löschen, Sperren)
|
||||
über alle Mandanten hinweg. Filtert nach Zeitraum, Benutzer,
|
||||
Tabelle und Aktion.
|
||||
<p 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,10 +9,11 @@ export default async function AdminGdprPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Mandantenübergreifende Übersicht aller registrierten Verarbeitungstätigkeiten
|
||||
gemäß Art. 30 DSGVO. Umfasst Zweck, Rechtsgrundlage, Datenkategorien,
|
||||
Aufbewahrungsfristen und technisch-organisatorische Maßnahmen.
|
||||
<p 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,23 +8,27 @@ export default async function AdminMigrationPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-6 space-y-4">
|
||||
<div className="space-y-4 rounded-lg border p-6">
|
||||
<h2 className="text-lg font-semibold">Migrationsschritte</h2>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm">
|
||||
<ol className="list-inside list-decimal space-y-2 text-sm">
|
||||
<li>MySQL-Verbindung konfigurieren</li>
|
||||
<li>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 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 p-4">
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
<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>
|
||||
|
||||
@@ -9,9 +9,10 @@ export default async function AdminModulesPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Hier werden alle Module über alle Mandanten hinweg angezeigt.
|
||||
Ermöglicht die zentrale Verwaltung von Modulvorlagen und -konfigurationen.
|
||||
Ermöglicht die zentrale Verwaltung von Modulvorlagen und
|
||||
-konfigurationen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
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;
|
||||
@@ -14,36 +18,73 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import { Mail } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { 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="min-h-screen bg-muted/30 flex items-center justify-center p-6">
|
||||
<div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<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 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>
|
||||
<CardTitle>Newsletter abonnieren</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Bleiben Sie über Neuigkeiten informiert.</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Bleiben Sie über Neuigkeiten informiert.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4">
|
||||
@@ -27,11 +32,19 @@ 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-xs text-center text-muted-foreground">
|
||||
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-muted-foreground text-center text-xs">
|
||||
Sie können sich jederzeit abmelden. Wir senden Ihnen eine
|
||||
Bestätigungs-E-Mail.
|
||||
</p>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,34 +1,51 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { MailX } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Props { params: Promise<{ slug: string }>; searchParams: Promise<{ token?: string }> }
|
||||
import { MailX } from 'lucide-react';
|
||||
|
||||
export default async function NewsletterUnsubscribePage({ params, searchParams }: Props) {
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ token?: string }>;
|
||||
}
|
||||
|
||||
export default async function NewsletterUnsubscribePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: Props) {
|
||||
const { slug } = await params;
|
||||
const { token } = await searchParams;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6">
|
||||
<div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
|
||||
<Card className="w-full max-w-md text-center">
|
||||
<CardHeader>
|
||||
<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 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>
|
||||
<CardTitle>Newsletter abbestellen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{token ? (
|
||||
<>
|
||||
<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">
|
||||
Möchten Sie den Newsletter wirklich abbestellen?
|
||||
</p>
|
||||
<Button variant="destructive" className="w-full">
|
||||
Abbestellen bestätigen
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Kein gültiger Abmeldelink. Bitte verwenden Sie den Link aus der Newsletter-E-Mail.</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Kein gültiger Abmeldelink. Bitte verwenden Sie den Link aus der
|
||||
Newsletter-E-Mail.
|
||||
</p>
|
||||
)}
|
||||
<Link href={`/club/${slug}`}>
|
||||
<Button variant="outline" size="sm">← Zurück zur Website</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
← Zurück zur Website
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
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;
|
||||
@@ -13,36 +17,74 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { FileText, Download, Shield, Receipt, FileCheck } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
import { FileText, Download, Shield, Receipt, FileCheck } from 'lucide-react';
|
||||
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
@@ -14,77 +18,117 @@ 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="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" />;
|
||||
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" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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="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="flex items-center gap-3">
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
<Shield className="text-primary h-5 w-5" />
|
||||
<h1 className="text-lg font-bold">Meine Dokumente</h1>
|
||||
</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="max-w-3xl mx-auto py-8 px-6">
|
||||
<main className="mx-auto max-w-3xl px-6 py-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verfügbare Dokumente</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{String(account.name)} — Dokumente und Rechnungen</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{String(account.name)} — Dokumente und Rechnungen
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{documents.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<FileText className="mx-auto h-10 w-10 mb-3" />
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
<FileText className="mx-auto mb-3 h-10 w-10" />
|
||||
<p>Keine Dokumente vorhanden</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{documents.map((doc) => (
|
||||
<div key={doc.id} className="flex items-center justify-between rounded-lg border p-4 hover:bg-muted/30 transition-colors">
|
||||
<div
|
||||
key={doc.id}
|
||||
className="hover:bg-muted/30 flex items-center justify-between rounded-lg border p-4 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{getIcon(doc.type)}
|
||||
<div>
|
||||
<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>
|
||||
<p className="text-sm font-medium">{doc.title}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{doc.type} — {formatDate(doc.date)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusBadge(doc.status)}
|
||||
<Button size="sm" variant="outline">
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
PDF
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
import { UserPlus, Shield, CheckCircle } from 'lucide-react';
|
||||
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
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;
|
||||
|
||||
@@ -24,28 +31,35 @@ export default async function PortalInvitePage({ params, searchParams }: Props)
|
||||
);
|
||||
|
||||
// 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="min-h-screen bg-muted/30 flex items-center justify-center p-6">
|
||||
<div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
|
||||
<Card className="max-w-md text-center">
|
||||
<CardContent className="p-8">
|
||||
<Shield className="mx-auto h-10 w-10 text-destructive mb-4" />
|
||||
<Shield className="text-destructive mx-auto mb-4 h-10 w-10" />
|
||||
<h2 className="text-lg font-bold">Einladung ungültig</h2>
|
||||
<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 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>
|
||||
<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>
|
||||
@@ -56,14 +70,14 @@ export default async function PortalInvitePage({ params, searchParams }: Props)
|
||||
const expired = new Date(invitation.expires_at) < new Date();
|
||||
if (expired) {
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6">
|
||||
<div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
|
||||
<Card className="max-w-md text-center">
|
||||
<CardContent className="p-8">
|
||||
<Shield className="mx-auto h-10 w-10 text-amber-500 mb-4" />
|
||||
<Shield className="mx-auto mb-4 h-10 w-10 text-amber-500" />
|
||||
<h2 className="text-lg font-bold">Einladung abgelaufen</h2>
|
||||
<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 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -72,41 +86,67 @@ export default async function PortalInvitePage({ params, searchParams }: Props)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6">
|
||||
<div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<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 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>
|
||||
<CardTitle>Einladung zum Mitgliederbereich</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{String(account.name)}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{String(account.name)}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md bg-primary/5 border border-primary/20 p-4 mb-6">
|
||||
<div className="bg-primary/5 border-primary/20 mb-6 rounded-md border p-4">
|
||||
<p className="text-sm">
|
||||
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-xs text-muted-foreground">Ihre E-Mail-Adresse wurde vom Verein vorgegeben.</p>
|
||||
<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>
|
||||
</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">
|
||||
@@ -115,8 +155,14 @@ export default async function PortalInvitePage({ params, searchParams }: Props)
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<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 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { UserCircle, FileText, CreditCard, Shield } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { 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 }>;
|
||||
@@ -18,15 +20,23 @@ 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)
|
||||
@@ -35,45 +45,61 @@ export default async function MemberPortalPage({ params }: Props) {
|
||||
if (member) {
|
||||
// Logged in member — show portal dashboard
|
||||
return (
|
||||
<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="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="flex items-center gap-3">
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-lg font-bold">Mitgliederbereich — {String(account.name)}</h1>
|
||||
<Shield className="text-primary h-5 w-5" />
|
||||
<h1 className="text-lg font-bold">
|
||||
Mitgliederbereich — {String(account.name)}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<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>
|
||||
<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>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Link href={`/club/${slug}/portal/profile`}>
|
||||
<Card className="hover:border-primary transition-colors cursor-pointer">
|
||||
<Card className="hover:border-primary cursor-pointer transition-colors">
|
||||
<CardContent className="p-6 text-center">
|
||||
<UserCircle className="mx-auto h-10 w-10 text-primary mb-3" />
|
||||
<UserCircle className="text-primary mx-auto mb-3 h-10 w-10" />
|
||||
<h3 className="font-semibold">Mein Profil</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">Kontaktdaten und Datenschutz</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Kontaktdaten und Datenschutz
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href={`/club/${slug}/portal/documents`}>
|
||||
<Card className="hover:border-primary transition-colors cursor-pointer">
|
||||
<Card className="hover:border-primary cursor-pointer transition-colors">
|
||||
<CardContent className="p-6 text-center">
|
||||
<FileText className="mx-auto h-10 w-10 text-primary mb-3" />
|
||||
<FileText className="text-primary mx-auto mb-3 h-10 w-10" />
|
||||
<h3 className="font-semibold">Dokumente</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">Rechnungen und Bescheinigungen</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Rechnungen und Bescheinigungen
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<CreditCard className="mx-auto h-10 w-10 text-primary mb-3" />
|
||||
<CreditCard className="text-primary mx-auto mb-3 h-10 w-10" />
|
||||
<h3 className="font-semibold">Mitgliedsausweis</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">Digital anzeigen</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Digital anzeigen
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -85,14 +111,18 @@ export default async function MemberPortalPage({ params }: Props) {
|
||||
|
||||
// Not logged in or not a member — show login form
|
||||
return (
|
||||
<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="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">
|
||||
<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="max-w-4xl mx-auto py-12 px-6">
|
||||
<main className="mx-auto max-w-4xl px-6 py-12">
|
||||
<PortalLoginForm slug={slug} accountName={String(account.name)} />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { Provider, UserIdentity } from '@supabase/supabase-js';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Link2, Link2Off, Loader2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@kit/ui/alert-dialog';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { OauthProviderLogoImage } from '@kit/ui/oauth-provider-logo-image';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
const PROVIDERS: Provider[] = ['google', 'apple', 'azure', 'github'];
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
google: 'Google',
|
||||
apple: 'Apple',
|
||||
azure: 'Microsoft',
|
||||
github: 'GitHub',
|
||||
};
|
||||
|
||||
function getSupabaseClient() {
|
||||
return createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
||||
);
|
||||
}
|
||||
|
||||
export function PortalLinkedAccounts({ slug }: { slug: string }) {
|
||||
const [identities, setIdentities] = useState<UserIdentity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
const loadIdentities = useCallback(async () => {
|
||||
const supabase = getSupabaseClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (user?.identities) {
|
||||
setIdentities(user.identities);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadIdentities();
|
||||
}, [loadIdentities]);
|
||||
|
||||
const handleLink = async (provider: Provider) => {
|
||||
setActionLoading(provider);
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseClient();
|
||||
const redirectTo = `${window.location.origin}/club/${slug}/portal/profile`;
|
||||
|
||||
const { error } = await supabase.auth.linkIdentity({
|
||||
provider,
|
||||
options: { redirectTo },
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(`Verknüpfung fehlgeschlagen: ${error.message}`);
|
||||
setActionLoading(null);
|
||||
}
|
||||
} catch {
|
||||
toast.error('Verbindungsfehler. Bitte versuchen Sie es erneut.');
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlink = async (identity: UserIdentity) => {
|
||||
if (identities.length <= 1) {
|
||||
toast.error('Sie benötigen mindestens eine Anmeldemethode.');
|
||||
return;
|
||||
}
|
||||
|
||||
setActionLoading(identity.id);
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseClient();
|
||||
const { error } = await supabase.auth.unlinkIdentity(identity);
|
||||
|
||||
if (error) {
|
||||
toast.error(`Trennung fehlgeschlagen: ${error.message}`);
|
||||
} else {
|
||||
toast.success(
|
||||
`${PROVIDER_LABELS[identity.provider] ?? identity.provider} wurde getrennt.`,
|
||||
);
|
||||
await loadIdentities();
|
||||
}
|
||||
} catch {
|
||||
toast.error('Verbindungsfehler. Bitte versuchen Sie es erneut.');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const connectedProviders = identities
|
||||
.filter((i) => i.provider !== 'email')
|
||||
.map((i) => i.provider);
|
||||
|
||||
const availableProviders = PROVIDERS.filter(
|
||||
(p) => !connectedProviders.includes(p),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Connected accounts */}
|
||||
{identities.filter((i) => i.provider !== 'email').length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
Verknüpfte Konten
|
||||
</p>
|
||||
|
||||
{identities
|
||||
.filter((i) => i.provider !== 'email')
|
||||
.map((identity) => (
|
||||
<div
|
||||
key={identity.id}
|
||||
className="bg-muted/50 flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center">
|
||||
<OauthProviderLogoImage providerId={identity.provider} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium capitalize">
|
||||
{PROVIDER_LABELS[identity.provider] ?? identity.provider}
|
||||
</p>
|
||||
{identity.identity_data?.email && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{identity.identity_data.email as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{identities.length > 1 && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={actionLoading === identity.id}
|
||||
>
|
||||
{actionLoading === identity.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Link2Off className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Konto trennen?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Möchten Sie die Verknüpfung mit{' '}
|
||||
{PROVIDER_LABELS[identity.provider] ??
|
||||
identity.provider}{' '}
|
||||
wirklich aufheben? Sie können sich dann nicht mehr
|
||||
darüber anmelden.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleUnlink(identity)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Trennen
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available providers to link */}
|
||||
{availableProviders.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
Konto verknüpfen für schnellere Anmeldung
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableProviders.map((provider) => (
|
||||
<Button
|
||||
key={provider}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={actionLoading === provider}
|
||||
onClick={() => handleLink(provider)}
|
||||
>
|
||||
{actionLoading === provider ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<OauthProviderLogoImage providerId={provider} />
|
||||
)}
|
||||
{PROVIDER_LABELS[provider] ?? provider}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info text when email-only */}
|
||||
{identities.length <= 1 && availableProviders.length > 0 && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Verknüpfen Sie ein Konto, um sich zukünftig schneller und ohne
|
||||
Passwort anmelden zu können.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,25 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
import {
|
||||
UserCircle,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
Shield,
|
||||
Calendar,
|
||||
Link2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { UserCircle, Mail, MapPin, Phone, Shield, Calendar } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PortalLinkedAccounts } from './_components/portal-linked-accounts';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string }>;
|
||||
@@ -19,15 +33,23 @@ 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)
|
||||
@@ -35,17 +57,20 @@ export default async function PortalProfilePage({ params }: Props) {
|
||||
|
||||
if (!member) {
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/30 flex items-center justify-center">
|
||||
<div className="bg-muted/30 flex min-h-screen items-center justify-center">
|
||||
<Card className="max-w-md">
|
||||
<CardContent className="p-8 text-center">
|
||||
<Shield className="mx-auto h-10 w-10 text-destructive mb-4" />
|
||||
<Shield className="text-destructive mx-auto mb-4 h-10 w-10" />
|
||||
<h2 className="text-lg font-bold">Kein Mitglied</h2>
|
||||
<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 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>
|
||||
<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>
|
||||
@@ -56,28 +81,35 @@ export default async function PortalProfilePage({ params }: Props) {
|
||||
const m = member;
|
||||
|
||||
return (
|
||||
<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="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="flex items-center gap-3">
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
<Shield className="text-primary h-5 w-5" />
|
||||
<h1 className="text-lg font-bold">Mein Profil</h1>
|
||||
</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="max-w-3xl mx-auto py-8 px-6 space-y-6">
|
||||
<main className="mx-auto max-w-3xl space-y-6 px-6 py-8">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<div className="bg-primary/10 text-primary flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<UserCircle className="h-8 w-8" />
|
||||
</div>
|
||||
<div>
|
||||
<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') : '—'}
|
||||
<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)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,37 +117,111 @@ 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"><Shield className="h-4 w-4" />Datenschutz-Einwilligungen</CardTitle></CardHeader>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4" />
|
||||
Anmeldemethoden
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PortalLinkedAccounts slug={slug} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Datenschutz-Einwilligungen
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{[
|
||||
{ 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="h-4 w-4 rounded border-input" />
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked={Boolean(value)}
|
||||
className="border-input h-4 w-4 rounded"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
@@ -21,8 +22,8 @@ import {
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string; bookingId: string }>;
|
||||
@@ -124,9 +125,7 @@ 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>
|
||||
@@ -144,7 +143,7 @@ export default async function BookingDetailPage({ params }: PageProps) {
|
||||
{room ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Zimmernummer
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
@@ -153,14 +152,14 @@ export default async function BookingDetailPage({ params }: PageProps) {
|
||||
</div>
|
||||
{room.name && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Name
|
||||
</span>
|
||||
<span className="font-medium">{String(room.name)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">Typ</span>
|
||||
<span className="text-muted-foreground text-sm">Typ</span>
|
||||
<span className="font-medium">
|
||||
{String(room.room_type ?? '—')}
|
||||
</span>
|
||||
@@ -186,29 +185,25 @@ export default async function BookingDetailPage({ params }: PageProps) {
|
||||
{guest ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">Name</span>
|
||||
<span className="text-muted-foreground text-sm">Name</span>
|
||||
<span className="font-medium">
|
||||
{String(guest.first_name)} {String(guest.last_name)}
|
||||
</span>
|
||||
</div>
|
||||
{guest.email && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
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-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Telefon
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{String(guest.phone)}
|
||||
</span>
|
||||
<span className="font-medium">{String(guest.phone)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -231,56 +226,30 @@ export default async function BookingDetailPage({ params }: PageProps) {
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Check-in
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{booking.check_in
|
||||
? new Date(String(booking.check_in)).toLocaleDateString(
|
||||
'de-DE',
|
||||
{
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
},
|
||||
)
|
||||
: '—'}
|
||||
{formatDate(booking.check_in)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Check-out
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{booking.check_out
|
||||
? new Date(String(booking.check_out)).toLocaleDateString(
|
||||
'de-DE',
|
||||
{
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
},
|
||||
)
|
||||
: '—'}
|
||||
{formatDate(booking.check_out)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
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-sm text-muted-foreground">
|
||||
Kinder
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{booking.children ?? 0}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm">Kinder</span>
|
||||
<span className="font-medium">{booking.children ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -294,7 +263,7 @@ export default async function BookingDetailPage({ params }: PageProps) {
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Gesamtpreis
|
||||
</span>
|
||||
<span className="text-2xl font-bold">
|
||||
@@ -305,7 +274,7 @@ export default async function BookingDetailPage({ params }: PageProps) {
|
||||
</div>
|
||||
{booking.notes && (
|
||||
<div className="border-t pt-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Notizen
|
||||
</span>
|
||||
<p className="mt-1 text-sm">{String(booking.notes)}</p>
|
||||
@@ -320,9 +289,7 @@ 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">
|
||||
@@ -350,10 +317,10 @@ export default async function BookingDetailPage({ params }: PageProps) {
|
||||
)}
|
||||
|
||||
{status === 'cancelled' || status === 'checked_out' ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
<p className="text-muted-foreground py-2 text-sm">
|
||||
Diese Buchung ist{' '}
|
||||
{status === 'cancelled' ? 'storniert' : 'abgeschlossen'} — keine
|
||||
weiteren Aktionen verfügbar.
|
||||
{status === 'cancelled' ? 'storniert' : 'abgeschlossen'} —
|
||||
keine weiteren Aktionen verfügbar.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -2,15 +2,14 @@ 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 { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -43,7 +42,11 @@ 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;
|
||||
}
|
||||
|
||||
@@ -101,7 +104,11 @@ 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++) {
|
||||
@@ -158,11 +165,11 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Weekday Header */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
<div className="mb-1 grid grid-cols-7 gap-1">
|
||||
{WEEKDAYS.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="text-center text-xs font-medium text-muted-foreground py-2"
|
||||
className="text-muted-foreground py-2 text-center text-xs font-medium"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
@@ -180,13 +187,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-2 ring-primary ring-offset-1' : ''}`}
|
||||
} ${cell.isToday ? 'ring-primary ring-2 ring-offset-1' : ''}`}
|
||||
>
|
||||
{cell.day !== null && (
|
||||
<>
|
||||
<span>{cell.day}</span>
|
||||
{cell.occupied && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
<span className="bg-primary absolute bottom-1 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -195,17 +202,17 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground mt-4 flex items-center gap-4 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-3 rounded-sm bg-primary/15" />
|
||||
<span className="bg-primary/15 inline-block h-3 w-3 rounded-sm" />
|
||||
Belegt
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-3 rounded-sm bg-muted/30" />
|
||||
<span className="bg-muted/30 inline-block h-3 w-3 rounded-sm" />
|
||||
Frei
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-3 rounded-sm ring-2 ring-primary" />
|
||||
<span className="ring-primary inline-block h-3 w-3 rounded-sm ring-2" />
|
||||
Heute
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,7 +224,7 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Buchungen in diesem Monat
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{bookings.data.length}</p>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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 { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
|
||||
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 }>;
|
||||
@@ -40,7 +39,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>
|
||||
<Button data-test="guests-new-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Gast
|
||||
</Button>
|
||||
@@ -62,7 +61,7 @@ export default async function GuestsPage({ params }: PageProps) {
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
||||
<th className="p-3 text-left font-medium">Telefon</th>
|
||||
@@ -72,9 +71,13 @@ export default async function GuestsPage({ params }: PageProps) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{guests.map((guest: Record<string, unknown>) => (
|
||||
<tr key={String(guest.id)} className="border-b hover:bg-muted/30">
|
||||
<tr
|
||||
key={String(guest.id)}
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
{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>
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
import { CreateBookingForm } from '@kit/booking-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewBookingPage({ params }: Props) {
|
||||
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">
|
||||
@@ -22,12 +29,19 @@ export default async function NewBookingPage({ params }: Props) {
|
||||
const rooms = await api.listRooms(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neue Buchung" description="Buchung erstellen">
|
||||
<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>
|
||||
|
||||
@@ -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 { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
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,7 +42,10 @@ 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();
|
||||
@@ -87,9 +90,9 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
|
||||
// Post-filter by search query (guest name or room name/number)
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
bookingsData = bookingsData.filter((b) => {
|
||||
const room = b.room as Record<string, string> | null;
|
||||
const guest = b.guest as Record<string, string> | null;
|
||||
bookingsData = bookingsData.filter((booking) => {
|
||||
const room = booking.room as Record<string, string> | null;
|
||||
const guest = booking.guest as Record<string, string> | null;
|
||||
const roomName = (room?.name ?? '').toLowerCase();
|
||||
const roomNumber = (room?.room_number ?? '').toLowerCase();
|
||||
const guestFirst = (guest?.first_name ?? '').toLowerCase();
|
||||
@@ -104,7 +107,8 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
|
||||
}
|
||||
|
||||
const activeBookings = bookingsData.filter(
|
||||
(b) => b.status === 'confirmed' || b.status === 'checked_in',
|
||||
(booking) =>
|
||||
booking.status === 'confirmed' || booking.status === 'checked_in',
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
@@ -119,7 +123,7 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
|
||||
</p>
|
||||
|
||||
<Link href={`/home/${account}/bookings/new`}>
|
||||
<Button>
|
||||
<Button data-test="bookings-new-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neue Buchung
|
||||
</Button>
|
||||
@@ -148,7 +152,7 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
|
||||
{/* Search */}
|
||||
<form className="flex items-center gap-2">
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
name="q"
|
||||
defaultValue={searchQuery}
|
||||
@@ -200,7 +204,7 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Zimmer</th>
|
||||
<th className="p-3 text-left font-medium">Gast</th>
|
||||
<th className="p-3 text-left font-medium">Anreise</th>
|
||||
@@ -211,13 +215,19 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
|
||||
</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="border-b hover:bg-muted/30"
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3">
|
||||
<Link
|
||||
@@ -235,18 +245,10 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{booking.check_in
|
||||
? new Date(
|
||||
String(booking.check_in),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
{formatDate(booking.check_in)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{booking.check_out
|
||||
? new Date(
|
||||
String(booking.check_out),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
{formatDate(booking.check_out)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
@@ -274,14 +276,12 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && !searchQuery && (
|
||||
<div className="flex items-center justify-between border-t px-2 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Seite {page} von {totalPages} ({total} Einträge)
|
||||
</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,9 +293,7 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
|
||||
)}
|
||||
|
||||
{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>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
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 { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
|
||||
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 }>;
|
||||
@@ -41,7 +40,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>
|
||||
<Button data-test="rooms-new-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neues Zimmer
|
||||
</Button>
|
||||
@@ -63,26 +62,37 @@ export default async function RoomsPage({ params }: PageProps) {
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Zimmernr.</th>
|
||||
<th className="p-3 text-left font-medium">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="border-b hover:bg-muted/30">
|
||||
<tr
|
||||
key={String(room.id)}
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
{String(room.room_number ?? '—')}
|
||||
</td>
|
||||
<td className="p-3 font-medium">{String(room.name ?? '—')}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="outline">{String(room.room_type ?? '—')}</Badge>
|
||||
<td className="p-3 font-medium">
|
||||
{String(room.name ?? '—')}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="outline">
|
||||
{String(room.room_type ?? '—')}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{String(room.capacity ?? '—')}
|
||||
</td>
|
||||
<td className="p-3 text-right">{String(room.capacity ?? '—')}</td>
|
||||
<td className="p-3 text-right">
|
||||
{room.price_per_night != null
|
||||
? `${Number(room.price_per_night).toFixed(2)} €`
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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 { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
@@ -14,7 +15,10 @@ 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();
|
||||
@@ -26,16 +30,23 @@ export default async function AttendancePage({ params, searchParams }: PageProps
|
||||
api.getParticipants(courseId),
|
||||
]);
|
||||
|
||||
if (!course) return <div>Kurs nicht gefunden</div>;
|
||||
if (!course) return <AccountNotFound />;
|
||||
|
||||
const selectedSessionId = (search.session as string) ?? (sessions.length > 0 ? String((sessions[0] as Record<string, unknown>).id) : null);
|
||||
const selectedSessionId =
|
||||
(search.session as string) ??
|
||||
(sessions.length > 0
|
||||
? String((sessions[0] as Record<string, unknown>).id)
|
||||
: null);
|
||||
|
||||
const attendance = selectedSessionId
|
||||
? 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 (
|
||||
@@ -70,9 +81,12 @@ export default async function AttendancePage({ params, searchParams }: PageProps
|
||||
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
|
||||
? new Date(String(s.session_date)).toLocaleDateString('de-DE')
|
||||
? formatDate(s.session_date as string)
|
||||
: String(s.id)}
|
||||
</Badge>
|
||||
</a>
|
||||
@@ -92,28 +106,38 @@ export default async function AttendancePage({ params, searchParams }: PageProps
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{participants.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
Keine Teilnehmer in diesem Kurs
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Teilnehmer</th>
|
||||
<th className="p-3 text-center font-medium">Anwesend</th>
|
||||
<tr 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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{participants.map((p: Record<string, unknown>) => (
|
||||
<tr key={String(p.id)} className="border-b hover:bg-muted/30">
|
||||
<tr
|
||||
key={String(p.id)}
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
{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)}`}
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
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 { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
@@ -16,13 +24,22 @@ 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) {
|
||||
@@ -36,75 +53,84 @@ export default async function CourseDetailPage({ params }: PageProps) {
|
||||
api.getSessions(courseId),
|
||||
]);
|
||||
|
||||
if (!course) return <div>Kurs nicht gefunden</div>;
|
||||
if (!course) return <AccountNotFound />;
|
||||
|
||||
const c = course as Record<string, unknown>;
|
||||
const courseData = course as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={String(c.name)}>
|
||||
<CmsPageShell account={account} title={String(courseData.name)}>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<GraduationCap className="h-5 w-5 text-primary" />
|
||||
<GraduationCap className="text-primary h-5 w-5" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Name</p>
|
||||
<p className="font-semibold">{String(c.name)}</p>
|
||||
<p className="text-muted-foreground text-xs">Name</p>
|
||||
<p className="font-semibold">{String(courseData.name)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<Clock className="h-5 w-5 text-primary" />
|
||||
<Clock className="text-primary h-5 w-5" />
|
||||
<div>
|
||||
<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)}
|
||||
<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)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
<User className="text-primary h-5 w-5" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Dozent</p>
|
||||
<p className="font-semibold">{String(c.instructor_id ?? '—')}</p>
|
||||
<p className="text-muted-foreground text-xs">Dozent</p>
|
||||
<p className="font-semibold">
|
||||
{String(courseData.instructor_id ?? '—')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<Calendar className="h-5 w-5 text-primary" />
|
||||
<Calendar className="text-primary h-5 w-5" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Beginn – Ende</p>
|
||||
<p className="text-muted-foreground text-xs">Beginn – Ende</p>
|
||||
<p className="font-semibold">
|
||||
{c.start_date ? new Date(String(c.start_date)).toLocaleDateString('de-DE') : '—'}
|
||||
{formatDate(courseData.start_date as string)}
|
||||
{' – '}
|
||||
{c.end_date ? new Date(String(c.end_date)).toLocaleDateString('de-DE') : '—'}
|
||||
{formatDate(courseData.end_date as string)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<Euro className="h-5 w-5 text-primary" />
|
||||
<Euro className="text-primary h-5 w-5" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Gebühr</p>
|
||||
<p className="text-muted-foreground text-xs">Gebühr</p>
|
||||
<p className="font-semibold">
|
||||
{c.fee != null ? `${Number(c.fee).toFixed(2)} €` : '—'}
|
||||
{courseData.fee != null
|
||||
? `${Number(courseData.fee).toFixed(2)} €`
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
<Users className="text-primary h-5 w-5" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Teilnehmer</p>
|
||||
<p className="text-muted-foreground text-xs">Teilnehmer</p>
|
||||
<p className="font-semibold">
|
||||
{participants.length} / {String(c.capacity ?? '∞')}
|
||||
{participants.length} / {String(courseData.capacity ?? '∞')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -116,14 +142,16 @@ 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="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
@@ -132,15 +160,36 @@ export default async function CourseDetailPage({ params }: PageProps) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{participants.length === 0 ? (
|
||||
<tr><td colSpan={4} className="p-6 text-center text-muted-foreground">Keine Teilnehmer</td></tr>
|
||||
) : participants.map((p: Record<string, unknown>) => (
|
||||
<tr key={String(p.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">{String(p.last_name ?? '')}, {String(p.first_name ?? '')}</td>
|
||||
<td className="p-3">{String(p.email ?? '—')}</td>
|
||||
<td className="p-3"><Badge variant="outline">{String(p.status ?? '—')}</Badge></td>
|
||||
<td className="p-3">{p.enrolled_at ? new Date(String(p.enrolled_at)).toLocaleDateString('de-DE') : '—'}</td>
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
className="text-muted-foreground p-6 text-center"
|
||||
>
|
||||
Keine Teilnehmer
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
) : (
|
||||
participants.map((p: Record<string, unknown>) => (
|
||||
<tr
|
||||
key={String(p.id)}
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
{String(p.last_name ?? '')},{' '}
|
||||
{String(p.first_name ?? '')}
|
||||
</td>
|
||||
<td className="p-3">{String(p.email ?? '—')}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="outline">
|
||||
{String(p.status ?? '—')}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{formatDate(p.enrolled_at as string)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -152,14 +201,16 @@ 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="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Datum</th>
|
||||
<th className="p-3 text-left font-medium">Beginn</th>
|
||||
<th className="p-3 text-left font-medium">Ende</th>
|
||||
@@ -168,15 +219,35 @@ export default async function CourseDetailPage({ params }: PageProps) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions.length === 0 ? (
|
||||
<tr><td colSpan={4} className="p-6 text-center text-muted-foreground">Keine Termine</td></tr>
|
||||
) : sessions.map((s: Record<string, unknown>) => (
|
||||
<tr key={String(s.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3">{s.session_date ? new Date(String(s.session_date)).toLocaleDateString('de-DE') : '—'}</td>
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
className="text-muted-foreground p-6 text-center"
|
||||
>
|
||||
Keine Termine
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sessions.map((s: Record<string, unknown>) => (
|
||||
<tr
|
||||
key={String(s.id)}
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3">
|
||||
{formatDate(s.session_date as string)}
|
||||
</td>
|
||||
<td className="p-3">{String(s.start_time ?? '—')}</td>
|
||||
<td className="p-3">{String(s.end_time ?? '—')}</td>
|
||||
<td className="p-3">{s.cancelled ? <Badge variant="destructive">Ja</Badge> : '—'}</td>
|
||||
<td className="p-3">
|
||||
{s.cancelled ? (
|
||||
<Badge variant="destructive">Ja</Badge>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,14 @@ 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 { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
@@ -16,7 +17,10 @@ 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',
|
||||
@@ -40,7 +44,7 @@ export default async function ParticipantsPage({ params }: PageProps) {
|
||||
api.getParticipants(courseId),
|
||||
]);
|
||||
|
||||
if (!course) return <div>Kurs nicht gefunden</div>;
|
||||
if (!course) return <AccountNotFound />;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Teilnehmer">
|
||||
@@ -49,10 +53,11 @@ 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>
|
||||
<Button data-test="participants-add-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Teilnehmer anmelden
|
||||
</Button>
|
||||
@@ -74,31 +79,39 @@ export default async function ParticipantsPage({ params }: PageProps) {
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">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="border-b hover:bg-muted/30">
|
||||
<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 ?? '')}
|
||||
{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">
|
||||
{p.enrolled_at
|
||||
? new Date(String(p.enrolled_at)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
{formatDate(p.enrolled_at as string)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -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 { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -67,10 +67,14 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
||||
const courseDates = new Set<number>();
|
||||
|
||||
for (const course of courses.data) {
|
||||
const c = course as Record<string, unknown>;
|
||||
if (c.status === 'cancelled') continue;
|
||||
const startDate = c.start_date ? new Date(String(c.start_date)) : null;
|
||||
const endDate = c.end_date ? new Date(String(c.end_date)) : null;
|
||||
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;
|
||||
|
||||
if (!startDate) continue;
|
||||
|
||||
@@ -86,7 +90,11 @@ 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 });
|
||||
@@ -96,7 +104,10 @@ 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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -105,8 +116,8 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
||||
}
|
||||
|
||||
const activeCourses = courses.data.filter(
|
||||
(c: Record<string, unknown>) =>
|
||||
c.status === 'open' || c.status === 'running',
|
||||
(courseItem: Record<string, unknown>) =>
|
||||
courseItem.status === 'open' || courseItem.status === 'running',
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -120,9 +131,7 @@ 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>
|
||||
|
||||
@@ -143,11 +152,11 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Weekday Header */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
<div className="mb-1 grid grid-cols-7 gap-1">
|
||||
{WEEKDAYS.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="text-center text-xs font-medium text-muted-foreground py-2"
|
||||
className="text-muted-foreground py-2 text-center text-xs font-medium"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
@@ -163,15 +172,15 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
||||
cell.day === null
|
||||
? 'bg-transparent'
|
||||
: cell.hasCourse
|
||||
? 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 font-semibold'
|
||||
? 'bg-emerald-500/15 font-semibold text-emerald-700 dark:text-emerald-400'
|
||||
: 'bg-muted/30 hover:bg-muted/50'
|
||||
} ${cell.isToday ? 'ring-2 ring-primary ring-offset-1' : ''}`}
|
||||
} ${cell.isToday ? 'ring-primary ring-2 ring-offset-1' : ''}`}
|
||||
>
|
||||
{cell.day !== null && (
|
||||
<>
|
||||
<span>{cell.day}</span>
|
||||
{cell.hasCourse && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
<span className="absolute bottom-1 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full bg-emerald-500" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -180,17 +189,17 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground mt-4 flex items-center gap-4 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-3 rounded-sm bg-emerald-500/15" />
|
||||
Kurstag
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-3 rounded-sm bg-muted/30" />
|
||||
<span className="bg-muted/30 inline-block h-3 w-3 rounded-sm" />
|
||||
Frei
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-3 rounded-sm ring-2 ring-primary" />
|
||||
<span className="ring-primary inline-block h-3 w-3 rounded-sm ring-2" />
|
||||
Heute
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,7 +213,7 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeCourses.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Keine aktiven Kurse in diesem Monat.
|
||||
</p>
|
||||
) : (
|
||||
@@ -221,18 +230,19 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
||||
>
|
||||
{String(course.name)}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{course.start_date
|
||||
? new Date(String(course.start_date)).toLocaleDateString('de-DE')
|
||||
: '—'}{' '}
|
||||
–{' '}
|
||||
{course.end_date
|
||||
? new Date(String(course.end_date)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatDate(course.start_date as string)} –{' '}
|
||||
{formatDate(course.end_date as string)}
|
||||
</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>
|
||||
))}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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 { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
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 }>;
|
||||
@@ -34,7 +33,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>
|
||||
<Button data-test="categories-new-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neue Kategorie
|
||||
</Button>
|
||||
@@ -56,17 +55,24 @@ export default async function CategoriesPage({ params }: PageProps) {
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">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="border-b hover:bg-muted/30">
|
||||
<tr
|
||||
key={String(cat.id)}
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-medium">{String(cat.name)}</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
<td className="text-muted-foreground p-3">
|
||||
{String(cat.description ?? '—')}
|
||||
</td>
|
||||
<td className="p-3">{String(cat.parent_id ?? '—')}</td>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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 { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
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 }>;
|
||||
@@ -34,7 +33,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>
|
||||
<Button data-test="instructors-new-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Dozent
|
||||
</Button>
|
||||
@@ -56,23 +55,33 @@ export default async function InstructorsPage({ params }: PageProps) {
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">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="border-b hover:bg-muted/30">
|
||||
<tr
|
||||
key={String(inst.id)}
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<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)} €`
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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 { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
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 }>;
|
||||
@@ -33,8 +32,10 @@ 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>
|
||||
<p className="text-muted-foreground">
|
||||
Kurs- und Veranstaltungsorte verwalten
|
||||
</p>
|
||||
<Button data-test="locations-new-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Ort
|
||||
</Button>
|
||||
@@ -56,7 +57,7 @@ export default async function LocationsPage({ params }: PageProps) {
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">Adresse</th>
|
||||
<th className="p-3 text-left font-medium">Raum</th>
|
||||
@@ -65,7 +66,10 @@ export default async function LocationsPage({ params }: PageProps) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{locations.map((loc: Record<string, unknown>) => (
|
||||
<tr key={String(loc.id)} className="border-b hover:bg-muted/30">
|
||||
<tr
|
||||
key={String(loc.id)}
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-medium">{String(loc.name)}</td>
|
||||
<td className="p-3">
|
||||
{[loc.street, loc.postal_code, loc.city]
|
||||
@@ -74,7 +78,9 @@ 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>
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { CreateCourseForm } from '@kit/course-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewCoursePage({ params }: Props) {
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
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 { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
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 { COURSE_STATUS_VARIANT, COURSE_STATUS_LABEL } from '~/lib/status-badges';
|
||||
import {
|
||||
COURSE_STATUS_VARIANT,
|
||||
COURSE_STATUS_LABEL,
|
||||
} from '~/lib/status-badges';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -50,12 +61,10 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* 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>
|
||||
<Button data-test="courses-new-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Kurs
|
||||
</Button>
|
||||
@@ -104,7 +113,7 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Kursnr.</th>
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">Beginn</th>
|
||||
@@ -116,7 +125,10 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{courses.data.map((course: Record<string, unknown>) => (
|
||||
<tr key={String(course.id)} className="border-b hover:bg-muted/30">
|
||||
<tr
|
||||
key={String(course.id)}
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
{String(course.course_number ?? '—')}
|
||||
</td>
|
||||
@@ -129,20 +141,20 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{course.start_date
|
||||
? new Date(String(course.start_date)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
{formatDate(course.start_date as string)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{course.end_date
|
||||
? new Date(String(course.end_date)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
{formatDate(course.end_date as string)}
|
||||
</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">
|
||||
@@ -164,7 +176,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-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Seite {page} von {totalPages} ({courses.total} Einträge)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { GraduationCap, Users, Calendar, TrendingUp, BarChart3 } from 'lucide-react';
|
||||
import {
|
||||
GraduationCap,
|
||||
Users,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
BarChart3,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { 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 }>;
|
||||
@@ -18,7 +23,11 @@ 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);
|
||||
@@ -34,10 +43,26 @@ 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">
|
||||
|
||||
@@ -68,12 +68,13 @@ 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:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
<option value="member-card">Mitgliedsausweis</option>
|
||||
<option value="invoice">Rechnung</option>
|
||||
@@ -92,7 +93,8 @@ 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 “{DOCUMENT_LABELS[selectedType]}”
|
||||
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>
|
||||
@@ -118,7 +120,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:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50"
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-50"
|
||||
>
|
||||
<option value="A4">A4</option>
|
||||
<option value="A5">A5</option>
|
||||
@@ -131,7 +133,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:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50"
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-50"
|
||||
>
|
||||
<option value="portrait">Hochformat</option>
|
||||
<option value="landscape">Querformat</option>
|
||||
@@ -140,7 +142,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Hint */}
|
||||
<div className="text-muted-foreground rounded-md bg-muted/50 p-4 text-sm">
|
||||
<div className="text-muted-foreground bg-muted/50 rounded-md p-4 text-sm">
|
||||
<p>
|
||||
<strong>Hinweis:</strong>{' '}
|
||||
{selectedType === 'member-card'
|
||||
@@ -189,7 +191,11 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
|
||||
|
||||
{/* Submit button */}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending || isComingSoon}>
|
||||
<Button
|
||||
type="submit"
|
||||
data-test="document-generate-btn"
|
||||
disabled={isPending || isComingSoon}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@@ -211,11 +217,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
|
||||
* Trigger a browser download from a base64 string.
|
||||
* 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++) {
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createDocumentGeneratorApi } from '@kit/document-generator/api';
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export type GenerateDocumentInput = {
|
||||
accountSlug: string;
|
||||
@@ -55,7 +57,11 @@ export async function generateDocumentAction(
|
||||
return { success: false, error: 'Unbekannter Dokumenttyp.' };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Document generation error:', err);
|
||||
const logger = await getLogger();
|
||||
logger.error(
|
||||
{ error: err, context: 'document-generation' },
|
||||
'Document generation error',
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Unbekannter Fehler.',
|
||||
@@ -73,8 +79,7 @@ const LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
function fmtDate(d: string | null): string {
|
||||
if (!d) return '–';
|
||||
try { return new Date(d).toLocaleDateString('de-DE'); } catch { return d; }
|
||||
return formatDate(d);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -88,16 +93,28 @@ 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';
|
||||
@@ -107,7 +124,13 @@ 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: {
|
||||
@@ -138,10 +161,22 @@ 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' },
|
||||
@@ -165,11 +200,22 @@ 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 ──
|
||||
@@ -184,10 +230,16 @@ 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 = new Date().toLocaleDateString('de-DE');
|
||||
const today = formatDate(new Date());
|
||||
const year = new Date().getFullYear();
|
||||
const cardsPerPage = 4;
|
||||
const pages: React.ReactElement[] = [];
|
||||
@@ -198,52 +250,122 @@ 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((m) =>
|
||||
React.createElement(View, { key: m.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((memberItem) =>
|
||||
React.createElement(
|
||||
View,
|
||||
{ key: memberItem.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. ${m.member_number ?? '–'}`),
|
||||
React.createElement(
|
||||
Text,
|
||||
{ style: s.memberNumber },
|
||||
`Nr. ${memberItem.member_number ?? '–'}`,
|
||||
),
|
||||
),
|
||||
|
||||
// Info column
|
||||
React.createElement(View, { style: s.infoCol },
|
||||
React.createElement(Text, { style: s.memberName }, `${m.first_name} ${m.last_name}`),
|
||||
React.createElement(View, { style: s.fieldGroup },
|
||||
React.createElement(
|
||||
View,
|
||||
{ style: s.infoCol },
|
||||
React.createElement(
|
||||
Text,
|
||||
{ style: s.memberName },
|
||||
`${memberItem.first_name} ${memberItem.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(m.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),
|
||||
),
|
||||
),
|
||||
// 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)),
|
||||
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),
|
||||
),
|
||||
),
|
||||
// Address
|
||||
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(
|
||||
View,
|
||||
{ style: { ...s.field, width: '100%' } },
|
||||
React.createElement(
|
||||
Text,
|
||||
{ style: s.fieldLabel },
|
||||
'Adresse',
|
||||
),
|
||||
React.createElement(Text, { style: { ...s.fieldValue, marginTop: 1 } },
|
||||
[m.postal_code, m.city].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(' ') || '',
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -251,12 +373,24 @@ 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.footerLeft },
|
||||
`Gültig ${year}/${year + 1}`,
|
||||
),
|
||||
),
|
||||
React.createElement(
|
||||
Text,
|
||||
{ style: s.footerRight },
|
||||
`Ausgestellt ${today}`,
|
||||
),
|
||||
React.createElement(Text, { style: s.footerRight }, `Ausgestellt ${today}`),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -285,19 +419,32 @@ async function generateLabels(
|
||||
): Promise<GenerateDocumentResult> {
|
||||
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((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 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 html = api.generateLabelsHtml({ labelFormat: 'avery-l7163', records });
|
||||
@@ -320,7 +467,9 @@ 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');
|
||||
|
||||
@@ -346,27 +495,42 @@ 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 m of members) {
|
||||
for (const member of members) {
|
||||
ws.addRow({
|
||||
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') : '',
|
||||
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) : '',
|
||||
});
|
||||
}
|
||||
|
||||
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' } } };
|
||||
});
|
||||
|
||||
@@ -379,7 +543,8 @@ 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`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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 { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -40,32 +40,28 @@ 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',
|
||||
},
|
||||
@@ -84,7 +80,11 @@ 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-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{docType.description}
|
||||
</p>
|
||||
<Link
|
||||
|
||||
@@ -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>
|
||||
<Button data-test="document-templates-new-btn">
|
||||
<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="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">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="border-b hover:bg-muted/30"
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-medium">{template.name}</td>
|
||||
<td className="p-3">{template.type}</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
<td className="text-muted-foreground p-3">
|
||||
{template.description}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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,7 +32,10 @@ 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',
|
||||
@@ -53,17 +56,21 @@ export default async function EventDetailPage({ params }: PageProps) {
|
||||
|
||||
if (!event) return <div>Veranstaltung nicht gefunden</div>;
|
||||
|
||||
const e = event as Record<string, unknown>;
|
||||
const eventData = event as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={String(e.name)}>
|
||||
<CmsPageShell account={account} title={String(eventData.name)}>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{String(e.name)}</h1>
|
||||
<Badge variant={STATUS_VARIANT[String(e.status)] ?? 'secondary'} className="mt-1">
|
||||
{STATUS_LABEL[String(e.status)] ?? String(e.status)}
|
||||
<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)}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button>
|
||||
@@ -76,44 +83,45 @@ export default async function EventDetailPage({ params }: PageProps) {
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<CalendarDays className="h-5 w-5 text-primary" />
|
||||
<CalendarDays className="text-primary h-5 w-5" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Datum</p>
|
||||
<p className="text-muted-foreground text-xs">Datum</p>
|
||||
<p className="font-semibold">
|
||||
{e.event_date
|
||||
? new Date(String(e.event_date)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
{formatDate(eventData.event_date as string)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<Clock className="h-5 w-5 text-primary" />
|
||||
<Clock className="text-primary h-5 w-5" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Uhrzeit</p>
|
||||
<p className="text-muted-foreground text-xs">Uhrzeit</p>
|
||||
<p className="font-semibold">
|
||||
{String(e.start_time ?? '—')} – {String(e.end_time ?? '—')}
|
||||
{String(eventData.start_time ?? '—')} –{' '}
|
||||
{String(eventData.end_time ?? '—')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<MapPin className="h-5 w-5 text-primary" />
|
||||
<MapPin className="text-primary h-5 w-5" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Ort</p>
|
||||
<p className="font-semibold">{String(e.location ?? '—')}</p>
|
||||
<p className="text-muted-foreground text-xs">Ort</p>
|
||||
<p className="font-semibold">
|
||||
{String(eventData.location ?? '—')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
<Users className="text-primary h-5 w-5" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Anmeldungen</p>
|
||||
<p className="text-muted-foreground text-xs">Anmeldungen</p>
|
||||
<p className="font-semibold">
|
||||
{registrations.length} / {String(e.capacity ?? '∞')}
|
||||
{registrations.length} / {String(eventData.capacity ?? '∞')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -121,14 +129,14 @@ export default async function EventDetailPage({ params }: PageProps) {
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{e.description ? (
|
||||
{eventData.description ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Beschreibung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{String(e.description)}
|
||||
<p className="text-muted-foreground text-sm whitespace-pre-wrap">
|
||||
{String(eventData.description)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -141,14 +149,14 @@ export default async function EventDetailPage({ params }: PageProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{registrations.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
Noch keine Anmeldungen
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
||||
<th className="p-3 text-left font-medium">Elternteil</th>
|
||||
@@ -157,16 +165,20 @@ export default async function EventDetailPage({ params }: PageProps) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{registrations.map((reg: Record<string, unknown>) => (
|
||||
<tr key={String(reg.id)} className="border-b hover:bg-muted/30">
|
||||
<tr
|
||||
key={String(reg.id)}
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
{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">
|
||||
{reg.created_at
|
||||
? new Date(String(reg.created_at)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
{String(reg.parent_name ?? '—')}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{formatDate(reg.created_at as string)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -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 { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createEventManagementApi } from '@kit/event-management/api';
|
||||
|
||||
import { 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 }>;
|
||||
@@ -37,7 +37,9 @@ 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" />
|
||||
@@ -55,23 +57,34 @@ 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="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">{t('name')}</th>
|
||||
<th className="p-3 text-left font-medium">{t('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="border-b hover:bg-muted/30">
|
||||
<tr
|
||||
key={String(pass.id)}
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-medium">{String(pass.name)}</td>
|
||||
<td className="p-3">{String(pass.year ?? '—')}</td>
|
||||
<td className="p-3 text-right">
|
||||
@@ -80,14 +93,10 @@ export default async function HolidayPassesPage({ params }: PageProps) {
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{pass.valid_from
|
||||
? new Date(String(pass.valid_from)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
{formatDate(pass.valid_from as string)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{pass.valid_until
|
||||
? new Date(String(pass.valid_until)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
{formatDate(pass.valid_until as string)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { CreateEventForm } from '@kit/event-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
import { CreateEventForm } from '@kit/event-management/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewEventPage({ params }: Props) {
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, MapPin, Plus, Users } from 'lucide-react';
|
||||
|
||||
import {
|
||||
CalendarDays,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
MapPin,
|
||||
Plus,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { 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 { AccountNotFound } from '~/components/account-not-found';
|
||||
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 {
|
||||
@@ -40,19 +47,21 @@ 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((e: Record<string, unknown>) => String(e.id));
|
||||
const eventIds = events.data.map((eventItem: Record<string, unknown>) =>
|
||||
String(eventItem.id),
|
||||
);
|
||||
const registrationCounts = await api.getRegistrationCounts(eventIds);
|
||||
|
||||
// Pre-compute stats before rendering
|
||||
const uniqueLocationCount = new Set(
|
||||
events.data
|
||||
.map((e: Record<string, unknown>) => e.location)
|
||||
.map((eventItem: Record<string, unknown>) => eventItem.location)
|
||||
.filter(Boolean),
|
||||
).size;
|
||||
|
||||
const totalCapacity = events.data.reduce(
|
||||
(sum: number, e: Record<string, unknown>) =>
|
||||
sum + (Number(e.capacity) || 0),
|
||||
(sum: number, eventItem: Record<string, unknown>) =>
|
||||
sum + (Number(eventItem.capacity) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -63,13 +72,11 @@ 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>
|
||||
<Button data-test="events-new-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('newEvent')}
|
||||
</Button>
|
||||
@@ -107,19 +114,31 @@ 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="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">{t('name')}</th>
|
||||
<th className="p-3 text-left font-medium">{t('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>
|
||||
@@ -130,7 +149,7 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
|
||||
return (
|
||||
<tr
|
||||
key={eventId}
|
||||
className="border-b hover:bg-muted/30"
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
<Link
|
||||
@@ -141,9 +160,7 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{event.event_date
|
||||
? new Date(String(event.event_date)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
{formatDate(event.event_date as string)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{String(event.location ?? '—')}
|
||||
@@ -156,10 +173,12 @@ 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">
|
||||
@@ -175,12 +194,17 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
|
||||
{/* Pagination */}
|
||||
{events.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('paginationPage', { page: events.page, totalPages: events.totalPages })}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{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')}
|
||||
@@ -188,7 +212,9 @@ 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" />
|
||||
|
||||
@@ -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 { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createEventManagementApi } from '@kit/event-management/api';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { 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,9 +64,7 @@ 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 */}
|
||||
@@ -108,17 +106,25 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<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>
|
||||
@@ -133,7 +139,7 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
|
||||
return (
|
||||
<tr
|
||||
key={event.id}
|
||||
className="border-b hover:bg-muted/30"
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
<Link
|
||||
@@ -143,17 +149,12 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
|
||||
{event.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{event.eventDate
|
||||
? new Date(event.eventDate).toLocaleDateString(
|
||||
'de-DE',
|
||||
)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">{formatDate(event.eventDate)}</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}
|
||||
|
||||
@@ -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 { createFinanceApi } from '@kit/finance/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
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 <div>Rechnung nicht gefunden</div>;
|
||||
if (!invoice) return <AccountNotFound />;
|
||||
|
||||
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-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Empfänger
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm font-semibold">
|
||||
@@ -95,31 +95,23 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Rechnungsdatum
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm font-semibold">
|
||||
{invoice.issue_date
|
||||
? new Date(
|
||||
String(invoice.issue_date),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
{formatDate(invoice.issue_date)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Fälligkeitsdatum
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm font-semibold">
|
||||
{invoice.due_date
|
||||
? new Date(String(invoice.due_date)).toLocaleDateString(
|
||||
'de-DE',
|
||||
)
|
||||
: '—'}
|
||||
{formatDate(invoice.due_date)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Gesamtbetrag
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm font-semibold">
|
||||
@@ -155,14 +147,14 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{items.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground py-8 text-center text-sm">
|
||||
Keine Positionen vorhanden.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">
|
||||
Beschreibung
|
||||
</th>
|
||||
@@ -177,7 +169,7 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
|
||||
{items.map((item) => (
|
||||
<tr
|
||||
key={String(item.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3">
|
||||
{String(item.description ?? '—')}
|
||||
@@ -199,7 +191,7 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t bg-muted/30">
|
||||
<tr className="bg-muted/30 border-t">
|
||||
<td colSpan={3} className="p-3 text-right font-medium">
|
||||
Zwischensumme
|
||||
</td>
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { CreateInvoiceForm } from '@kit/finance/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewInvoicePage({ params }: Props) {
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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 { createFinanceApi } from '@kit/finance/api';
|
||||
|
||||
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';
|
||||
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="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Nr.</th>
|
||||
<th className="p-3 text-left font-medium">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="border-b hover:bg-muted/30"
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
<Link
|
||||
@@ -106,18 +106,10 @@ export default async function InvoicesPage({ params }: PageProps) {
|
||||
{String(invoice.recipient_name ?? '—')}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{invoice.issue_date
|
||||
? new Date(
|
||||
String(invoice.issue_date),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
{formatDate(invoice.issue_date)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{invoice.due_date
|
||||
? new Date(
|
||||
String(invoice.due_date),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
{formatDate(invoice.due_date)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{invoice.total_amount != null
|
||||
|
||||
@@ -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 { createFinanceApi } from '@kit/finance/api';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
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,9 +61,7 @@ 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`}>
|
||||
@@ -124,7 +122,7 @@ export default async function FinancePage({ params }: PageProps) {
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-left font-medium">Typ</th>
|
||||
<th className="p-3 text-right font-medium">Betrag</th>
|
||||
@@ -135,15 +133,17 @@ export default async function FinancePage({ params }: PageProps) {
|
||||
{batches.map((batch: Record<string, unknown>) => (
|
||||
<tr
|
||||
key={String(batch.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<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,11 +157,7 @@ export default async function FinancePage({ params }: PageProps) {
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{batch.execution_date
|
||||
? new Date(String(batch.execution_date)).toLocaleDateString('de-DE')
|
||||
: batch.created_at
|
||||
? new Date(String(batch.created_at)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
{formatDate(batch.execution_date ?? batch.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -196,7 +192,7 @@ export default async function FinancePage({ params }: PageProps) {
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Nr.</th>
|
||||
<th className="p-3 text-left font-medium">Empfänger</th>
|
||||
<th className="p-3 text-right font-medium">Betrag</th>
|
||||
@@ -207,7 +203,7 @@ export default async function FinancePage({ params }: PageProps) {
|
||||
{invoices.map((invoice: Record<string, unknown>) => (
|
||||
<tr
|
||||
key={String(invoice.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
<Link
|
||||
|
||||
@@ -2,16 +2,15 @@ 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 { createFinanceApi } from '@kit/finance/api';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
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 }>;
|
||||
@@ -120,12 +119,14 @@ 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="mb-4 text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
{openInvoices.length > 0
|
||||
? `${openInvoices.length} Rechnungen mit einem Gesamtbetrag von ${formatCurrency(openTotal)} sind offen.`
|
||||
: 'Keine offenen Rechnungen vorhanden.'}
|
||||
@@ -147,7 +148,7 @@ export default async function PaymentsPage({ params }: PageProps) {
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
{batches.length > 0
|
||||
? `${batches.length} SEPA-Einzüge mit einem Gesamtvolumen von ${formatCurrency(sepaTotal)}.`
|
||||
: 'Keine SEPA-Einzüge vorhanden.'}
|
||||
|
||||
@@ -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 { createFinanceApi } from '@kit/finance/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
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 <div>Einzug nicht gefunden</div>;
|
||||
if (!batch) return <AccountNotFound />;
|
||||
|
||||
const status = String(batch.status);
|
||||
|
||||
@@ -95,9 +95,7 @@ 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>
|
||||
@@ -105,7 +103,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-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Typ
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm font-semibold">
|
||||
@@ -115,7 +113,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Betrag
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm font-semibold">
|
||||
@@ -125,7 +123,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Anzahl
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm font-semibold">
|
||||
@@ -133,15 +131,11 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Ausführungsdatum
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm font-semibold">
|
||||
{batch.execution_date
|
||||
? new Date(
|
||||
String(batch.execution_date),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
{formatDate(batch.execution_date)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -162,14 +156,14 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{items.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground py-8 text-center text-sm">
|
||||
Keine Positionen vorhanden.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">IBAN</th>
|
||||
<th className="p-3 text-right font-medium">Betrag</th>
|
||||
@@ -182,7 +176,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
|
||||
return (
|
||||
<tr
|
||||
key={String(item.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
{String(item.debtor_name ?? '—')}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CreateSepaBatchForm } from '@kit/finance/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { CreateSepaBatchForm } from '@kit/finance/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -21,7 +21,11 @@ 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>
|
||||
);
|
||||
|
||||
@@ -2,20 +2,17 @@ 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 { createFinanceApi } from '@kit/finance/api';
|
||||
|
||||
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';
|
||||
import {
|
||||
BATCH_STATUS_VARIANT,
|
||||
BATCH_STATUS_LABEL,
|
||||
} from '~/lib/status-badges';
|
||||
import { BATCH_STATUS_VARIANT, BATCH_STATUS_LABEL } from '~/lib/status-badges';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -79,7 +76,7 @@ export default async function SepaPage({ params }: PageProps) {
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-left font-medium">Typ</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
@@ -96,12 +93,13 @@ export default async function SepaPage({ params }: PageProps) {
|
||||
{batches.map((batch: Record<string, unknown>) => (
|
||||
<tr
|
||||
key={String(batch.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<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)] ??
|
||||
@@ -130,11 +128,7 @@ export default async function SepaPage({ params }: PageProps) {
|
||||
{String(batch.item_count ?? 0)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{batch.execution_date
|
||||
? new Date(
|
||||
String(batch.execution_date),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
{formatDate(batch.execution_date)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import { FischereiTabNavigation, CatchBooksDataTable } from '@kit/fischerei/components';
|
||||
import {
|
||||
FischereiTabNavigation,
|
||||
CatchBooksDataTable,
|
||||
} from '@kit/fischerei/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import { FischereiTabNavigation, CompetitionsDataTable } from '@kit/fischerei/components';
|
||||
import {
|
||||
FischereiTabNavigation,
|
||||
CompetitionsDataTable,
|
||||
} from '@kit/fischerei/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
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();
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import { FischereiTabNavigation } from '@kit/fischerei/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { LEASE_PAYMENT_LABELS } from '@kit/fischerei/lib/fischerei-constants';
|
||||
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 { 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="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
|
||||
Erstellen Sie Ihren ersten Pachtvertrag.
|
||||
</p>
|
||||
</div>
|
||||
@@ -62,22 +62,32 @@ export default async function LeasesPage({ params, searchParams }: Props) {
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Verpächter</th>
|
||||
<th className="p-3 text-left font-medium">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="border-b hover:bg-muted/30">
|
||||
<tr
|
||||
key={String(lease.id)}
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
{String(lease.lessor_name)}
|
||||
</td>
|
||||
@@ -85,13 +95,11 @@ export default async function LeasesPage({ params, searchParams }: Props) {
|
||||
{waters ? String(waters.name) : '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{lease.start_date
|
||||
? new Date(String(lease.start_date)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
{formatDate(lease.start_date)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{lease.end_date
|
||||
? new Date(String(lease.end_date)).toLocaleDateString('de-DE')
|
||||
? formatDate(lease.end_date)
|
||||
: 'unbefristet'}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
@@ -101,7 +109,8 @@ 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>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import { FischereiTabNavigation, FischereiDashboard } from '@kit/fischerei/components';
|
||||
import {
|
||||
FischereiTabNavigation,
|
||||
FischereiDashboard,
|
||||
} from '@kit/fischerei/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import { FischereiTabNavigation } from '@kit/fischerei/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
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';
|
||||
|
||||
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="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
|
||||
Erstellen Sie Ihren ersten Erlaubnisschein.
|
||||
</p>
|
||||
</div>
|
||||
@@ -53,22 +53,36 @@ export default async function PermitsPage({ params }: Props) {
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Bezeichnung</th>
|
||||
<th className="p-3 text-left font-medium">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="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">{String(permit.name)}</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
<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">
|
||||
{String(permit.short_code ?? '—')}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
FischereiTabNavigation,
|
||||
CreateSpeciesForm,
|
||||
} from '@kit/fischerei/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { FischereiTabNavigation, CreateSpeciesForm } from '@kit/fischerei/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import { FischereiTabNavigation, SpeciesDataTable } from '@kit/fischerei/components';
|
||||
import {
|
||||
FischereiTabNavigation,
|
||||
SpeciesDataTable,
|
||||
} from '@kit/fischerei/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { FischereiTabNavigation } from '@kit/fischerei/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -37,9 +37,12 @@ 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="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
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="text-muted-foreground mt-1 max-w-sm text-sm">
|
||||
Sobald Fangbücher eingereicht und geprüft werden, erscheinen
|
||||
hier Statistiken und Auswertungen.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import { FischereiTabNavigation, CreateStockingForm } from '@kit/fischerei/components';
|
||||
import {
|
||||
FischereiTabNavigation,
|
||||
CreateStockingForm,
|
||||
} from '@kit/fischerei/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import { FischereiTabNavigation, StockingDataTable } from '@kit/fischerei/components';
|
||||
import {
|
||||
FischereiTabNavigation,
|
||||
StockingDataTable,
|
||||
} from '@kit/fischerei/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
FischereiTabNavigation,
|
||||
CreateWaterForm,
|
||||
} from '@kit/fischerei/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { FischereiTabNavigation, CreateWaterForm } from '@kit/fischerei/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import { FischereiTabNavigation, WatersDataTable } from '@kit/fischerei/components';
|
||||
import {
|
||||
FischereiTabNavigation,
|
||||
WatersDataTable,
|
||||
} from '@kit/fischerei/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
|
||||
@@ -70,7 +70,8 @@ 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;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMeetingsApi } from '@kit/sitzungsprotokolle/api';
|
||||
import { MeetingsTabNavigation, MeetingsDashboard } from '@kit/sitzungsprotokolle/components';
|
||||
import {
|
||||
MeetingsTabNavigation,
|
||||
MeetingsDashboard,
|
||||
} from '@kit/sitzungsprotokolle/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
|
||||
@@ -2,18 +2,20 @@ 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 { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMeetingsApi } from '@kit/sitzungsprotokolle/api';
|
||||
import { MeetingsTabNavigation, ProtocolItemsList } from '@kit/sitzungsprotokolle/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
import { MEETING_TYPE_LABELS } from '@kit/sitzungsprotokolle/lib/meetings-constants';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string; protocolId: string }>;
|
||||
@@ -39,9 +41,12 @@ export default async function ProtocolDetailPage({ params }: PageProps) {
|
||||
} catch {
|
||||
return (
|
||||
<CmsPageShell account={account} title="Sitzungsprotokolle">
|
||||
<div className="text-center py-12">
|
||||
<div className="py-12 text-center">
|
||||
<h2 className="text-lg font-semibold">Protokoll nicht gefunden</h2>
|
||||
<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>
|
||||
@@ -72,18 +77,12 @@ export default async function ProtocolDetailPage({ params }: PageProps) {
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">{protocol.title}</CardTitle>
|
||||
<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>
|
||||
<div className="text-muted-foreground mt-2 flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>{formatDateFull(protocol.meeting_date)}</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>
|
||||
@@ -97,20 +96,28 @@ 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-sm font-medium text-muted-foreground">Ort</p>
|
||||
<p className="text-muted-foreground text-sm font-medium">Ort</p>
|
||||
<p className="text-sm">{protocol.location}</p>
|
||||
</div>
|
||||
)}
|
||||
{protocol.attendees && (
|
||||
<div className="sm:col-span-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Teilnehmer</p>
|
||||
<p className="text-sm whitespace-pre-line">{protocol.attendees}</p>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Teilnehmer
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-line">
|
||||
{protocol.attendees}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{protocol.remarks && (
|
||||
<div className="sm:col-span-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Anmerkungen</p>
|
||||
<p className="text-sm whitespace-pre-line">{protocol.remarks}</p>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Anmerkungen
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-line">
|
||||
{protocol.remarks}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
MeetingsTabNavigation,
|
||||
CreateProtocolForm,
|
||||
} from '@kit/sitzungsprotokolle/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { MeetingsTabNavigation, CreateProtocolForm } from '@kit/sitzungsprotokolle/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMeetingsApi } from '@kit/sitzungsprotokolle/api';
|
||||
import { MeetingsTabNavigation, ProtocolsDataTable } from '@kit/sitzungsprotokolle/components';
|
||||
import {
|
||||
MeetingsTabNavigation,
|
||||
ProtocolsDataTable,
|
||||
} from '@kit/sitzungsprotokolle/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
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();
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMeetingsApi } from '@kit/sitzungsprotokolle/api';
|
||||
import { MeetingsTabNavigation, OpenTasksView } from '@kit/sitzungsprotokolle/components';
|
||||
import {
|
||||
MeetingsTabNavigation,
|
||||
OpenTasksView,
|
||||
} from '@kit/sitzungsprotokolle/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -36,7 +39,8 @@ 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
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { EditMemberForm } from '@kit/member-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string; memberId: string }>;
|
||||
@@ -11,7 +12,11 @@ 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);
|
||||
@@ -19,7 +24,10 @@ 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>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { MemberDetailView } from '@kit/member-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string; memberId: string }>;
|
||||
@@ -11,7 +12,11 @@ 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);
|
||||
@@ -19,7 +24,10 @@ 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>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { ApplicationWorkflow } from '@kit/member-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -11,15 +12,27 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { CreditCard } from 'lucide-react';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { 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;
|
||||
@@ -15,15 +17,26 @@ 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" />}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
import { createDepartment } from '@kit/member-management/actions/member-actions';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -16,15 +18,17 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { createDepartment } from '@kit/member-management/actions/member-actions';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
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('');
|
||||
@@ -49,7 +53,11 @@ export function CreateDepartmentDialog({ accountId }: CreateDepartmentDialogProp
|
||||
(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],
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
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 { 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';
|
||||
|
||||
@@ -14,14 +16,22 @@ 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} />
|
||||
@@ -37,16 +47,21 @@ export default async function DepartmentsPage({ params }: Props) {
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{departments.map((dept: Record<string, unknown>) => (
|
||||
<tr key={String(dept.id)} className="border-b hover:bg-muted/30">
|
||||
<tr
|
||||
key={String(dept.id)}
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-medium">{String(dept.name)}</td>
|
||||
<td className="p-3 text-muted-foreground">{String(dept.description ?? '—')}</td>
|
||||
<td className="text-muted-foreground p-3">
|
||||
{String(dept.description ?? '—')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { DuesCategoryManager } from '@kit/member-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -11,14 +12,22 @@ 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>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { MemberImportWizard } from '@kit/member-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -10,11 +11,19 @@ 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>
|
||||
);
|
||||
|
||||
@@ -1,28 +1,43 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { CreateMemberForm } from '@kit/member-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewMemberPage({ params }: Props) {
|
||||
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">
|
||||
<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)
|
||||
}))}
|
||||
duesCategories={(duesCategories ?? []).map(
|
||||
(c: Record<string, unknown>) => ({
|
||||
id: String(c.id),
|
||||
name: String(c.name),
|
||||
amount: Number(c.amount ?? 0),
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { MembersDataTable } from '@kit/member-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -15,7 +16,11 @@ 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);
|
||||
@@ -29,16 +34,23 @@ 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>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { Users, UserCheck, UserMinus, Clock, BarChart3, TrendingUp } from 'lucide-react';
|
||||
import {
|
||||
Users,
|
||||
UserCheck,
|
||||
UserMinus,
|
||||
Clock,
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { 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 }>;
|
||||
@@ -40,10 +46,26 @@ 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">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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';
|
||||
|
||||
@@ -49,7 +51,11 @@ async function loadAccountMembers(
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
const logger = await getLogger();
|
||||
logger.error(
|
||||
{ error, context: 'load-account-members' },
|
||||
'Failed to load account members',
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -70,7 +76,11 @@ async function loadInvitations(
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
const logger = await getLogger();
|
||||
logger.error(
|
||||
{ error, context: 'load-invitations' },
|
||||
'Failed to load account invitations',
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Pencil, Trash2, Lock, Unlock } from 'lucide-react';
|
||||
|
||||
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
||||
import { ModuleForm } from '@kit/module-builder/components';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Pencil, Trash2, Lock, Unlock } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
@@ -14,7 +16,9 @@ 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);
|
||||
@@ -26,29 +30,49 @@ export default async function RecordDetailPage({ params }: RecordDetailPageProps
|
||||
|
||||
if (!moduleWithFields || !record) return <div>Nicht gefunden</div>;
|
||||
|
||||
const fields = (moduleWithFields as unknown as {
|
||||
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;
|
||||
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;
|
||||
section: string;
|
||||
sort_order: number;
|
||||
show_in_form: boolean;
|
||||
width: string;
|
||||
}>;
|
||||
}).fields;
|
||||
}
|
||||
).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-sm text-muted-foreground">
|
||||
Erstellt: {new Date(record.created_at).toLocaleDateString('de-DE')}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Erstellt: {formatDate(record.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Upload, ArrowRight, CheckCircle } from 'lucide-react';
|
||||
|
||||
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Upload, ArrowRight, CheckCircle } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
@@ -19,22 +19,45 @@ 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="h-4 w-4 text-muted-foreground" />}
|
||||
<span
|
||||
className={`text-sm ${i === 0 ? 'font-semibold' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{step}
|
||||
</span>
|
||||
{i < 3 && (
|
||||
<ArrowRight className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -49,21 +72,30 @@ 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="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>
|
||||
<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>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
className="mt-4 block w-full max-w-xs text-sm text-muted-foreground file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground hover:file:bg-primary/90"
|
||||
className="text-muted-foreground file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 mt-4 block w-full max-w-xs text-sm file:mr-4 file:rounded-md file:border-0 file:px-4 file:py-2 file:text-sm file:font-semibold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold mb-2">Verfügbare Zielfelder:</h4>
|
||||
<h4 className="mb-2 text-sm font-semibold">
|
||||
Verfügbare Zielfelder:
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{fields.map((field) => (
|
||||
<span key={field.name} className="rounded-md bg-muted px-2 py-1 text-xs">
|
||||
<span
|
||||
key={field.name}
|
||||
className="bg-muted rounded-md px-2 py-1 text-xs"
|
||||
>
|
||||
{field.display_name}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
||||
import { ModuleForm } from '@kit/module-builder/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface NewRecordPageProps {
|
||||
@@ -16,18 +16,30 @@ 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 {
|
||||
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;
|
||||
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;
|
||||
section: string;
|
||||
sort_order: number;
|
||||
show_in_form: boolean;
|
||||
width: string;
|
||||
}>;
|
||||
}).fields;
|
||||
}
|
||||
).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']}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
interface ModuleDetailPageProps {
|
||||
params: Promise<{ account: string; moduleId: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function ModuleDetailPage({ params, searchParams }: ModuleDetailPageProps) {
|
||||
export default async function ModuleDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: ModuleDetailPageProps) {
|
||||
const { account, moduleId } = await params;
|
||||
const search = await searchParams;
|
||||
const client = getSupabaseServerClient();
|
||||
@@ -20,14 +22,21 @@ export default async function ModuleDetailPage({ params, searchParams }: ModuleD
|
||||
}
|
||||
|
||||
const page = Number(search.page) || 1;
|
||||
const pageSize = Number(search.pageSize) || moduleWithFields.default_page_size || 25;
|
||||
const pageSize =
|
||||
Number(search.pageSize) || moduleWithFields.default_page_size || 25;
|
||||
|
||||
const result = await api.query.query({
|
||||
moduleId,
|
||||
page,
|
||||
pageSize,
|
||||
sortField: (search.sort as string) ?? moduleWithFields.default_sort_field ?? undefined,
|
||||
sortDirection: (search.dir as 'asc' | 'desc') ?? (moduleWithFields.default_sort_direction as 'asc' | 'desc') ?? 'asc',
|
||||
sortField:
|
||||
(search.sort as string) ??
|
||||
moduleWithFields.default_sort_field ??
|
||||
undefined,
|
||||
sortDirection:
|
||||
(search.dir as 'asc' | 'desc') ??
|
||||
(moduleWithFields.default_sort_direction as 'asc' | 'desc') ??
|
||||
'asc',
|
||||
search: (search.q as string) ?? undefined,
|
||||
filters: [],
|
||||
});
|
||||
@@ -36,20 +45,25 @@ export default async function ModuleDetailPage({ params, searchParams }: ModuleD
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{moduleWithFields.display_name}</h1>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{moduleWithFields.display_name}
|
||||
</h1>
|
||||
{moduleWithFields.description && (
|
||||
<p className="text-muted-foreground">{moduleWithFields.description}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{moduleWithFields.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{result.pagination.total} Datensätze — Seite {result.pagination.page} von {result.pagination.totalPages}
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{result.pagination.total} Datensätze — Seite {result.pagination.page}{' '}
|
||||
von {result.pagination.totalPages}
|
||||
</div>
|
||||
|
||||
{/* Phase 3 will replace this with module-table component */}
|
||||
<div className="rounded-lg border">
|
||||
<pre className="p-4 text-xs overflow-auto max-h-96">
|
||||
<pre className="max-h-96 overflow-auto p-4 text-xs">
|
||||
{JSON.stringify(result.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Settings2, List, Shield } from 'lucide-react';
|
||||
|
||||
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
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 { Label } from '@kit/ui/label';
|
||||
import { Settings2, List, Shield } from 'lucide-react';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
@@ -14,7 +14,9 @@ interface ModuleSettingsPageProps {
|
||||
params: Promise<{ account: string; moduleId: string }>;
|
||||
}
|
||||
|
||||
export default async function ModuleSettingsPage({ params }: ModuleSettingsPageProps) {
|
||||
export default async function ModuleSettingsPage({
|
||||
params,
|
||||
}: ModuleSettingsPageProps) {
|
||||
const { account, moduleId } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createModuleBuilderApi(client);
|
||||
@@ -23,10 +25,14 @@ export default async function ModuleSettingsPage({ params }: ModuleSettingsPageP
|
||||
if (!moduleWithFields) return <div>Modul nicht gefunden</div>;
|
||||
|
||||
const mod = moduleWithFields;
|
||||
const fields = (mod as unknown as { fields: Array<Record<string, unknown>> }).fields ?? [];
|
||||
const fields =
|
||||
(mod as unknown as { fields: Array<Record<string, unknown>> }).fields ?? [];
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={`${String(mod.display_name)} — Einstellungen`}>
|
||||
<CmsPageShell
|
||||
account={account}
|
||||
title={`${String(mod.display_name)} — Einstellungen`}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* General Settings */}
|
||||
<Card>
|
||||
@@ -44,7 +50,11 @@ export default async function ModuleSettingsPage({ params }: ModuleSettingsPageP
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Systemname</Label>
|
||||
<Input defaultValue={String(mod.name)} readOnly className="bg-muted" />
|
||||
<Input
|
||||
defaultValue={String(mod.name)}
|
||||
readOnly
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-full space-y-2">
|
||||
<Label>Beschreibung</Label>
|
||||
@@ -56,7 +66,10 @@ export default async function ModuleSettingsPage({ params }: ModuleSettingsPageP
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Seitengröße</Label>
|
||||
<Input type="number" defaultValue={String(mod.default_page_size ?? 25)} />
|
||||
<Input
|
||||
type="number"
|
||||
defaultValue={String(mod.default_page_size ?? 25)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -73,7 +86,11 @@ export default async function ModuleSettingsPage({ params }: ModuleSettingsPageP
|
||||
].map(({ key, label }) => (
|
||||
<Badge
|
||||
key={key}
|
||||
variant={(mod as Record<string, unknown>)[key] ? 'default' : 'secondary'}
|
||||
variant={
|
||||
(mod as Record<string, unknown>)[key]
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{(mod as Record<string, unknown>)[key] ? '✓' : '✗'} {label}
|
||||
</Badge>
|
||||
@@ -96,7 +113,7 @@ export default async function ModuleSettingsPage({ params }: ModuleSettingsPageP
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left">Name</th>
|
||||
<th className="p-3 text-left">Anzeigename</th>
|
||||
<th className="p-3 text-left">Typ</th>
|
||||
@@ -108,21 +125,35 @@ export default async function ModuleSettingsPage({ params }: ModuleSettingsPageP
|
||||
<tbody>
|
||||
{fields.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-8 text-center text-muted-foreground">
|
||||
<td
|
||||
colSpan={6}
|
||||
className="text-muted-foreground p-8 text-center"
|
||||
>
|
||||
Noch keine Felder definiert
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
fields.map((field) => (
|
||||
<tr key={String(field.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-mono text-xs">{String(field.name)}</td>
|
||||
<tr
|
||||
key={String(field.id)}
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
{String(field.name)}
|
||||
</td>
|
||||
<td className="p-3">{String(field.display_name)}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="secondary">{String(field.field_type)}</Badge>
|
||||
<Badge variant="secondary">
|
||||
{String(field.field_type)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3">{field.is_required ? '✓' : '—'}</td>
|
||||
<td className="p-3">{field.show_in_table ? '✓' : '—'}</td>
|
||||
<td className="p-3">{field.show_in_form ? '✓' : '—'}</td>
|
||||
<td className="p-3">
|
||||
{field.show_in_table ? '✓' : '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{field.show_in_form ? '✓' : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
@@ -141,8 +172,9 @@ export default async function ModuleSettingsPage({ params }: ModuleSettingsPageP
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Modulspezifische Berechtigungen pro Rolle können hier konfiguriert werden.
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Modulspezifische Berechtigungen pro Rolle können hier konfiguriert
|
||||
werden.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
import { Fish, FileSignature, Building2 } from 'lucide-react';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Fish, FileSignature, Building2 } from 'lucide-react';
|
||||
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Switch } from '@kit/ui/switch';
|
||||
|
||||
import { toggleModuleAction } from '../_lib/server/toggle-module';
|
||||
@@ -55,9 +56,7 @@ export function ModuleToggles({ accountId, features }: ModuleTogglesProps) {
|
||||
const result = await toggleModuleAction(accountId, moduleKey, enabled);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
enabled ? 'Modul aktiviert' : 'Modul deaktiviert',
|
||||
);
|
||||
toast.success(enabled ? 'Modul aktiviert' : 'Modul deaktiviert');
|
||||
|
||||
router.refresh();
|
||||
} else {
|
||||
|
||||
@@ -18,8 +18,7 @@ export async function toggleModuleAction(
|
||||
.eq('account_id', accountId)
|
||||
.maybeSingle();
|
||||
|
||||
const currentFeatures =
|
||||
(settings?.features as Record<string, boolean>) ?? {};
|
||||
const currentFeatures = (settings?.features as Record<string, boolean>) ?? {};
|
||||
const newFeatures = { ...currentFeatures, [moduleKey]: enabled };
|
||||
|
||||
// Upsert
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
import { ModuleToggles } from './_components/module-toggles';
|
||||
|
||||
@@ -61,17 +60,15 @@ export default async function ModulesPage({ params }: ModulesPageProps) {
|
||||
<Link
|
||||
key={module.id as string}
|
||||
href={`/home/${account}/modules/${module.id as string}`}
|
||||
className="block rounded-lg border p-4 hover:bg-accent/50 transition-colors"
|
||||
className="hover:bg-accent/50 block rounded-lg border p-4 transition-colors"
|
||||
>
|
||||
<h3 className="font-semibold">
|
||||
{String(module.display_name)}
|
||||
</h3>
|
||||
<h3 className="font-semibold">{String(module.display_name)}</h3>
|
||||
{module.description ? (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{String(module.description)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground mt-2 text-xs">
|
||||
Status: {String(module.status)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -2,16 +2,15 @@ import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft, Send, Users } from 'lucide-react';
|
||||
|
||||
import { createNewsletterApi } from '@kit/newsletter/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 { createNewsletterApi } from '@kit/newsletter/api';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import {
|
||||
NEWSLETTER_STATUS_VARIANT,
|
||||
NEWSLETTER_STATUS_LABEL,
|
||||
@@ -42,7 +41,7 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
|
||||
api.getRecipients(campaignId),
|
||||
]);
|
||||
|
||||
if (!newsletter) return <div>Newsletter nicht gefunden</div>;
|
||||
if (!newsletter) return <AccountNotFound />;
|
||||
|
||||
const status = String(newsletter.status);
|
||||
const sentCount = recipients.filter(
|
||||
@@ -61,6 +60,7 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
|
||||
<Link
|
||||
href={`/home/${account}/newsletter`}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
||||
data-test="newsletter-back-link"
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Zurück zu Newsletter
|
||||
@@ -99,7 +99,7 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
|
||||
{/* Actions */}
|
||||
{status === 'draft' && (
|
||||
<div className="mt-6">
|
||||
<Button>
|
||||
<Button data-test="newsletter-send-btn">
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Newsletter versenden
|
||||
</Button>
|
||||
@@ -115,7 +115,7 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recipients.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground py-8 text-center text-sm">
|
||||
Keine Empfänger hinzugefügt. Fügen Sie Empfänger aus Ihrer
|
||||
Mitgliederliste hinzu.
|
||||
</p>
|
||||
@@ -123,7 +123,7 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
@@ -135,7 +135,7 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
|
||||
return (
|
||||
<tr
|
||||
key={String(recipient.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
{String(recipient.name ?? '—')}
|
||||
@@ -146,10 +146,12 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
NEWSLETTER_RECIPIENT_STATUS_VARIANT[rStatus] ?? 'secondary'
|
||||
NEWSLETTER_RECIPIENT_STATUS_VARIANT[rStatus] ??
|
||||
'secondary'
|
||||
}
|
||||
>
|
||||
{NEWSLETTER_RECIPIENT_STATUS_LABEL[rStatus] ?? rStatus}
|
||||
{NEWSLETTER_RECIPIENT_STATUS_LABEL[rStatus] ??
|
||||
rStatus}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { CreateNewsletterForm } from '@kit/newsletter/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewNewsletterPage({ params }: Props) {
|
||||
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 Newsletter" description="Newsletter-Kampagne erstellen">
|
||||
<CmsPageShell
|
||||
account={account}
|
||||
title="Neuer Newsletter"
|
||||
description="Newsletter-Kampagne erstellen"
|
||||
>
|
||||
<CreateNewsletterForm accountId={acct.id} account={account} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronLeft, ChevronRight, Mail, Plus, Send, Users } from 'lucide-react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Mail,
|
||||
Plus,
|
||||
Send,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { createNewsletterApi } from '@kit/newsletter/api';
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createNewsletterApi } from '@kit/newsletter/api';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
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 {
|
||||
NEWSLETTER_STATUS_VARIANT,
|
||||
NEWSLETTER_STATUS_LABEL,
|
||||
@@ -25,7 +32,10 @@ interface PageProps {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function NewsletterPage({ params, searchParams }: PageProps) {
|
||||
export default async function NewsletterPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
const { account } = await params;
|
||||
const search = await searchParams;
|
||||
const client = getSupabaseServerClient();
|
||||
@@ -72,7 +82,7 @@ export default async function NewsletterPage({ params, searchParams }: PageProps
|
||||
</div>
|
||||
|
||||
<Link href={`/home/${account}/newsletter/new`}>
|
||||
<Button>
|
||||
<Button data-test="newsletter-new-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Newsletter
|
||||
</Button>
|
||||
@@ -116,7 +126,7 @@ export default async function NewsletterPage({ params, searchParams }: PageProps
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Betreff</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-right font-medium">Empfänger</th>
|
||||
@@ -128,7 +138,7 @@ export default async function NewsletterPage({ params, searchParams }: PageProps
|
||||
{newsletters.map((nl: Record<string, unknown>) => (
|
||||
<tr
|
||||
key={String(nl.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
<Link
|
||||
@@ -141,10 +151,12 @@ export default async function NewsletterPage({ params, searchParams }: PageProps
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
NEWSLETTER_STATUS_VARIANT[String(nl.status)] ?? 'secondary'
|
||||
NEWSLETTER_STATUS_VARIANT[String(nl.status)] ??
|
||||
'secondary'
|
||||
}
|
||||
>
|
||||
{NEWSLETTER_STATUS_LABEL[String(nl.status)] ?? String(nl.status)}
|
||||
{NEWSLETTER_STATUS_LABEL[String(nl.status)] ??
|
||||
String(nl.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
@@ -152,16 +164,8 @@ export default async function NewsletterPage({ params, searchParams }: PageProps
|
||||
? String(nl.total_recipients)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{nl.created_at
|
||||
? new Date(String(nl.created_at)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{nl.sent_at
|
||||
? new Date(String(nl.sent_at)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">{formatDate(nl.created_at)}</td>
|
||||
<td className="p-3">{formatDate(nl.sent_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -171,12 +175,15 @@ export default async function NewsletterPage({ params, searchParams }: PageProps
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{startIdx + 1}–{Math.min(startIdx + PAGE_SIZE, totalItems)} von {totalItems}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{startIdx + 1}–{Math.min(startIdx + PAGE_SIZE, totalItems)}{' '}
|
||||
von {totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{safePage > 1 ? (
|
||||
<Link href={`/home/${account}/newsletter?page=${safePage - 1}`}>
|
||||
<Link
|
||||
href={`/home/${account}/newsletter?page=${safePage - 1}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -192,7 +199,9 @@ export default async function NewsletterPage({ params, searchParams }: PageProps
|
||||
</span>
|
||||
|
||||
{safePage < totalPages ? (
|
||||
<Link href={`/home/${account}/newsletter?page=${safePage + 1}`}>
|
||||
<Link
|
||||
href={`/home/${account}/newsletter?page=${safePage + 1}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -2,16 +2,15 @@ import Link from 'next/link';
|
||||
|
||||
import { FileText, Plus } from 'lucide-react';
|
||||
|
||||
import { createNewsletterApi } from '@kit/newsletter/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 { createNewsletterApi } from '@kit/newsletter/api';
|
||||
|
||||
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 }>;
|
||||
@@ -44,7 +43,7 @@ export default async function NewsletterTemplatesPage({ params }: PageProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button>
|
||||
<Button data-test="newsletter-templates-new-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neue Vorlage
|
||||
</Button>
|
||||
@@ -67,7 +66,7 @@ export default async function NewsletterTemplatesPage({ params }: PageProps) {
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">Betreff</th>
|
||||
<th className="p-3 text-left font-medium">Variablen</th>
|
||||
@@ -81,7 +80,7 @@ export default async function NewsletterTemplatesPage({ params }: PageProps) {
|
||||
return (
|
||||
<tr
|
||||
key={String(template.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
{String(template.name ?? '—')}
|
||||
@@ -91,8 +90,8 @@ export default async function NewsletterTemplatesPage({ params }: PageProps) {
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{variables.length > 0
|
||||
? variables.map((v) => (
|
||||
{variables.length > 0 ? (
|
||||
variables.map((v) => (
|
||||
<Badge
|
||||
key={v}
|
||||
variant="secondary"
|
||||
@@ -101,7 +100,9 @@ export default async function NewsletterTemplatesPage({ params }: PageProps) {
|
||||
{`{{${v}}}`}
|
||||
</Badge>
|
||||
))
|
||||
: <span className="text-muted-foreground">—</span>}
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -13,9 +13,15 @@ import {
|
||||
BedDouble,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
import { createEventManagementApi } from '@kit/event-management/api';
|
||||
import { createFinanceApi } from '@kit/finance/api';
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { createNewsletterApi } from '@kit/newsletter/api';
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -24,17 +30,10 @@ import {
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
import { createFinanceApi } from '@kit/finance/api';
|
||||
import { createNewsletterApi } from '@kit/newsletter/api';
|
||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
import { createEventManagementApi } from '@kit/event-management/api';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
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 TeamAccountHomePageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -79,7 +78,12 @@ export default async function TeamAccountHomePage({
|
||||
const courseStats =
|
||||
courseStatsResult.status === 'fulfilled'
|
||||
? courseStatsResult.value
|
||||
: { totalCourses: 0, openCourses: 0, completedCourses: 0, totalParticipants: 0 };
|
||||
: {
|
||||
totalCourses: 0,
|
||||
openCourses: 0,
|
||||
completedCourses: 0,
|
||||
totalParticipants: 0,
|
||||
};
|
||||
|
||||
const openInvoices =
|
||||
invoicesResult.status === 'fulfilled' ? invoicesResult.value : [];
|
||||
@@ -145,7 +149,9 @@ export default async function TeamAccountHomePage({
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Recent bookings */}
|
||||
{bookings.data.slice(0, 3).map((booking: Record<string, unknown>) => (
|
||||
{bookings.data
|
||||
.slice(0, 3)
|
||||
.map((booking: Record<string, unknown>) => (
|
||||
<div
|
||||
key={String(booking.id)}
|
||||
className="flex items-center justify-between rounded-md border p-3"
|
||||
@@ -161,26 +167,12 @@ export default async function TeamAccountHomePage({
|
||||
>
|
||||
Buchung{' '}
|
||||
{booking.check_in
|
||||
? new Date(
|
||||
String(booking.check_in),
|
||||
).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
})
|
||||
? formatDate(booking.check_in as string)
|
||||
: '—'}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{booking.check_in
|
||||
? new Date(
|
||||
String(booking.check_in),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}{' '}
|
||||
–{' '}
|
||||
{booking.check_out
|
||||
? new Date(
|
||||
String(booking.check_out),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatDate(booking.check_in as string)} –{' '}
|
||||
{formatDate(booking.check_out as string)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -191,7 +183,9 @@ export default async function TeamAccountHomePage({
|
||||
))}
|
||||
|
||||
{/* Recent events */}
|
||||
{events.data.slice(0, 3).map((event: Record<string, unknown>) => (
|
||||
{events.data
|
||||
.slice(0, 3)
|
||||
.map((event: Record<string, unknown>) => (
|
||||
<div
|
||||
key={String(event.id)}
|
||||
className="flex items-center justify-between rounded-md border p-3"
|
||||
@@ -207,12 +201,8 @@ export default async function TeamAccountHomePage({
|
||||
>
|
||||
{String(event.name)}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{event.event_date
|
||||
? new Date(
|
||||
String(event.event_date),
|
||||
).toLocaleDateString('de-DE')
|
||||
: 'Kein Datum'}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatDate(event.event_date as string)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,7 +232,7 @@ export default async function TeamAccountHomePage({
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<Link
|
||||
href={`/home/${account}/members-cms/new`}
|
||||
className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
|
||||
className="border-border bg-background hover:bg-muted hover:text-foreground inline-flex w-full items-center justify-between gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-all"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<UserPlus className="h-4 w-4" />
|
||||
@@ -253,7 +243,7 @@ export default async function TeamAccountHomePage({
|
||||
|
||||
<Link
|
||||
href={`/home/${account}/courses/new`}
|
||||
className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
|
||||
className="border-border bg-background hover:bg-muted hover:text-foreground inline-flex w-full items-center justify-between gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-all"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<GraduationCap className="h-4 w-4" />
|
||||
@@ -264,7 +254,7 @@ export default async function TeamAccountHomePage({
|
||||
|
||||
<Link
|
||||
href={`/home/${account}/newsletter/new`}
|
||||
className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
|
||||
className="border-border bg-background hover:bg-muted hover:text-foreground inline-flex w-full items-center justify-between gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-all"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
@@ -275,7 +265,7 @@ export default async function TeamAccountHomePage({
|
||||
|
||||
<Link
|
||||
href={`/home/${account}/bookings/new`}
|
||||
className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
|
||||
className="border-border bg-background hover:bg-muted hover:text-foreground inline-flex w-full items-center justify-between gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-all"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<BedDouble className="h-4 w-4" />
|
||||
@@ -286,7 +276,7 @@ export default async function TeamAccountHomePage({
|
||||
|
||||
<Link
|
||||
href={`/home/${account}/events/new`}
|
||||
className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
|
||||
className="border-border bg-background hover:bg-muted hover:text-foreground inline-flex w-full items-center justify-between gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-all"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -304,21 +294,23 @@ export default async function TeamAccountHomePage({
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Buchungen
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{bookings.total}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{bookings.data.filter(
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{
|
||||
bookings.data.filter(
|
||||
(b: Record<string, unknown>) =>
|
||||
b.status === 'confirmed' || b.status === 'checked_in',
|
||||
).length}{' '}
|
||||
).length
|
||||
}{' '}
|
||||
aktiv
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/home/${account}/bookings`}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
|
||||
className="hover:bg-muted hover:text-foreground inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
@@ -330,22 +322,24 @@ export default async function TeamAccountHomePage({
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Veranstaltungen
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{events.total}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{events.data.filter(
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{
|
||||
events.data.filter(
|
||||
(e: Record<string, unknown>) =>
|
||||
e.status === 'published' ||
|
||||
e.status === 'registration_open',
|
||||
).length}{' '}
|
||||
).length
|
||||
}{' '}
|
||||
aktiv
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/home/${account}/events`}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
|
||||
className="hover:bg-muted hover:text-foreground inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
@@ -357,19 +351,19 @@ export default async function TeamAccountHomePage({
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Kurse abgeschlossen
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{courseStats.completedCourses}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
von {courseStats.totalCourses} insgesamt
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/home/${account}/courses`}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
|
||||
className="hover:bg-muted hover:text-foreground inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createSiteBuilderApi } from '@kit/site-builder/api';
|
||||
import { SiteEditor } from '@kit/site-builder/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props { params: Promise<{ account: string; pageId: string }> }
|
||||
interface Props {
|
||||
params: Promise<{ account: string; pageId: string }>;
|
||||
}
|
||||
|
||||
export default async function EditPageRoute({ params }: Props) {
|
||||
const { account, pageId } = 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 = createSiteBuilderApi(client);
|
||||
const page = await api.getPage(pageId);
|
||||
if (!page) return <div>Seite nicht gefunden</div>;
|
||||
if (!page) return <AccountNotFound />;
|
||||
|
||||
return <SiteEditor pageId={pageId} accountId={acct.id} initialData={(page.puck_data ?? {}) as Record<string, unknown>} />;
|
||||
return (
|
||||
<SiteEditor
|
||||
pageId={pageId}
|
||||
accountId={acct.id}
|
||||
initialData={(page.puck_data ?? {}) as Record<string, unknown>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { CreatePageForm } from '@kit/site-builder/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -10,11 +11,19 @@ interface Props {
|
||||
export default async function NewSitePage({ 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 Seite" description="Seite für Ihre Vereinswebsite erstellen">
|
||||
<CmsPageShell
|
||||
account={account}
|
||||
title="Neue Seite"
|
||||
description="Seite für Ihre Vereinswebsite erstellen"
|
||||
>
|
||||
<CreatePageForm accountId={acct.id} account={account} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Plus, Globe, FileText, Settings, ExternalLink } from 'lucide-react';
|
||||
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
import { createSiteBuilderApi } from '@kit/site-builder/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
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 { cn } from '@kit/ui/utils';
|
||||
import { Plus, Globe, FileText, Settings, ExternalLink } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function SiteBuilderDashboard({ params }: Props) {
|
||||
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 = createSiteBuilderApi(client);
|
||||
@@ -24,39 +34,72 @@ export default async function SiteBuilderDashboard({ params }: Props) {
|
||||
const posts = await api.listPosts(acct.id);
|
||||
|
||||
const isOnline = Boolean(settings?.is_public);
|
||||
const publishedCount = pages.filter((p: Record<string, unknown>) => p.is_published).length;
|
||||
const publishedCount = pages.filter(
|
||||
(p: Record<string, unknown>) => p.is_published,
|
||||
).length;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Website-Baukasten" description="Ihre Vereinswebseite verwalten">
|
||||
<CmsPageShell
|
||||
account={account}
|
||||
title="Website-Baukasten"
|
||||
description="Ihre Vereinswebseite verwalten"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/home/${account}/site-builder/settings`}>
|
||||
<Button variant="outline" size="sm"><Settings className="mr-2 h-4 w-4" />Einstellungen</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Einstellungen
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/home/${account}/site-builder/posts`}>
|
||||
<Button variant="outline" size="sm"><FileText className="mr-2 h-4 w-4" />Beiträge ({posts.length})</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Beiträge ({posts.length})
|
||||
</Button>
|
||||
</Link>
|
||||
{isOnline && (
|
||||
<a href={`/club/${account}`} target="_blank" rel="noopener">
|
||||
<Button variant="outline" size="sm"><ExternalLink className="mr-2 h-4 w-4" />Website ansehen</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Website ansehen
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<Link href={`/home/${account}/site-builder/new`}>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />Neue Seite</Button>
|
||||
<Button data-test="site-new-page-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neue Seite
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Card><CardContent className="p-6"><p className="text-sm text-muted-foreground">Seiten</p><p className="text-2xl font-bold">{pages.length}</p></CardContent></Card>
|
||||
<Card><CardContent className="p-6"><p className="text-sm text-muted-foreground">Veröffentlicht</p><p className="text-2xl font-bold">{publishedCount}</p></CardContent></Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm text-muted-foreground">Status</p>
|
||||
<p className="text-muted-foreground text-sm">Seiten</p>
|
||||
<p className="text-2xl font-bold">{pages.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-muted-foreground text-sm">Veröffentlicht</p>
|
||||
<p className="text-2xl font-bold">{publishedCount}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-muted-foreground text-sm">Status</p>
|
||||
<p className="text-2xl font-bold">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={cn('inline-block h-2 w-2 rounded-full', isOnline ? 'bg-green-500' : 'bg-red-500')} />
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block h-2 w-2 rounded-full',
|
||||
isOnline ? 'bg-green-500' : 'bg-red-500',
|
||||
)}
|
||||
/>
|
||||
<span>{isOnline ? 'Online' : 'Offline'}</span>
|
||||
</span>
|
||||
</p>
|
||||
@@ -75,25 +118,44 @@ export default async function SiteBuilderDashboard({ params }: Props) {
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b bg-muted/50">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">Titel</th>
|
||||
<th className="p-3 text-left font-medium">URL</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-left font-medium">Startseite</th>
|
||||
<th className="p-3 text-left font-medium">Aktualisiert</th>
|
||||
<th className="p-3 text-left font-medium">Aktionen</th>
|
||||
</tr></thead>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pages.map((page: Record<string, unknown>) => (
|
||||
<tr key={String(page.id)} className="border-b hover:bg-muted/30">
|
||||
<tr
|
||||
key={String(page.id)}
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-medium">{String(page.title)}</td>
|
||||
<td className="p-3 text-muted-foreground font-mono text-xs">/{String(page.slug)}</td>
|
||||
<td className="p-3"><Badge variant={page.is_published ? 'default' : 'secondary'}>{page.is_published ? 'Veröffentlicht' : 'Entwurf'}</Badge></td>
|
||||
<td className="p-3">{page.is_homepage ? '⭐' : '—'}</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">{page.updated_at ? new Date(String(page.updated_at)).toLocaleDateString('de-DE') : '—'}</td>
|
||||
<td className="text-muted-foreground p-3 font-mono text-xs">
|
||||
/{String(page.slug)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Link href={`/home/${account}/site-builder/${String(page.id)}/edit`}>
|
||||
<Button size="sm" variant="outline">Bearbeiten</Button>
|
||||
<Badge
|
||||
variant={page.is_published ? 'default' : 'secondary'}
|
||||
>
|
||||
{page.is_published ? 'Veröffentlicht' : 'Entwurf'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3">{page.is_homepage ? '⭐' : '—'}</td>
|
||||
<td className="text-muted-foreground p-3 text-xs">
|
||||
{formatDate(page.updated_at)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/home/${account}/site-builder/${String(page.id)}/edit`}
|
||||
>
|
||||
<Button size="sm" variant="outline">
|
||||
Bearbeiten
|
||||
</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user