feat: MyEasyCMS v2 — Full SaaS rebuild
Complete rebuild of 22-year-old PHP CMS as modern SaaS: Database (15 migrations, 42+ tables): - Foundation: account_settings, audit_log, GDPR register, cms_files - Module Engine: modules, fields, records, permissions, relations + RPC - Members: 45+ field member profiles, departments, roles, honors, SEPA mandates - Courses: courses, sessions, categories, instructors, locations, attendance - Bookings: rooms, guests, bookings with availability - Events: events, registrations, holiday passes - Finance: SEPA batches/items (pain.008/001 XML), invoices - Newsletter: campaigns, templates, recipients, subscriptions - Site Builder: site_pages (Puck JSON), site_settings, cms_posts - Portal Auth: member_portal_invitations, user linking Feature Packages (9): - @kit/module-builder — dynamic low-code CRUD engine - @kit/member-management — 31 API methods, 21 actions, 8 components - @kit/course-management, @kit/booking-management, @kit/event-management - @kit/finance — SEPA XML generator + IBAN validator - @kit/newsletter — campaigns + dispatch - @kit/document-generator — PDF/Excel/Word - @kit/site-builder — Puck visual editor, 15 blocks, public rendering Pages (60+): - Dashboard with real stats from all APIs - Full CRUD for all 8 domains with react-hook-form + Zod - Recharts statistics - German i18n throughout - Member portal with auth + invitation system - Public club websites via Puck at /club/[slug] Infrastructure: - Dockerfile (multi-stage, standalone output) - docker-compose.yml (Supabase self-hosted + Next.js) - Kong API gateway config - .env.production.example
This commit is contained in:
@@ -1,19 +1,34 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowRightIcon, LayoutDashboard } from 'lucide-react';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
BookOpenIcon,
|
||||
CalendarIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
LayoutDashboardIcon,
|
||||
MailIcon,
|
||||
ShieldCheckIcon,
|
||||
UsersIcon,
|
||||
WalletIcon,
|
||||
BedDoubleIcon,
|
||||
GlobeIcon,
|
||||
ZapIcon,
|
||||
HeadsetIcon,
|
||||
LockIcon,
|
||||
SmartphoneIcon,
|
||||
CheckIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PricingTable } from '@kit/billing-gateway/marketing';
|
||||
import {
|
||||
CtaButton,
|
||||
EcosystemShowcase,
|
||||
FeatureCard,
|
||||
FeatureGrid,
|
||||
FeatureShowcase,
|
||||
FeatureShowcaseIconContainer,
|
||||
Hero,
|
||||
Pill,
|
||||
PillActionButton,
|
||||
SecondaryHero,
|
||||
} from '@kit/ui/marketing';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
@@ -23,31 +38,25 @@ import pathsConfig from '~/config/paths.config';
|
||||
|
||||
function Home() {
|
||||
return (
|
||||
<div className={'mt-4 flex flex-col space-y-24 py-14'}>
|
||||
<div className={'mt-4 flex flex-col space-y-24 py-14 lg:space-y-36'}>
|
||||
{/* Hero Section */}
|
||||
<div className={'mx-auto'}>
|
||||
<Hero
|
||||
pill={
|
||||
<Pill label={'New'}>
|
||||
<span>The SaaS Starter Kit for ambitious developers</span>
|
||||
<PillActionButton
|
||||
render={
|
||||
<Link href={'/auth/sign-up'}>
|
||||
<ArrowRightIcon className={'h-4 w-4'} />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Pill label={'Neu'}>
|
||||
<span>
|
||||
<Trans i18nKey={'marketing.heroPill'} />
|
||||
</span>
|
||||
</Pill>
|
||||
}
|
||||
title={
|
||||
<span className="text-secondary-foreground">
|
||||
<span>Ship a SaaS faster than ever.</span>
|
||||
<Trans i18nKey={'marketing.heroTitle'} />
|
||||
</span>
|
||||
}
|
||||
subtitle={
|
||||
<span>
|
||||
Makerkit gives you a production-ready boilerplate to build your
|
||||
SaaS faster than ever before with the next-gen SaaS Starter Kit.
|
||||
Get started in minutes.
|
||||
<Trans i18nKey={'marketing.heroSubtitle'} />
|
||||
</span>
|
||||
}
|
||||
cta={<MainCallToActionButton />}
|
||||
@@ -55,95 +64,227 @@ function Home() {
|
||||
<Image
|
||||
priority
|
||||
className={
|
||||
'dark:border-primary/10 w-full rounded-lg border border-gray-200'
|
||||
'dark:border-primary/10 w-full rounded-2xl border border-gray-200 shadow-2xl'
|
||||
}
|
||||
width={3558}
|
||||
height={2222}
|
||||
src={`/images/dashboard.webp`}
|
||||
alt={`App Image`}
|
||||
alt={`MyEasyCMS Dashboard`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<Trans i18nKey={'marketing.trustedBy'} />
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-12 gap-y-6">
|
||||
<TrustItem icon={UsersIcon} label="marketing.trustAssociations" />
|
||||
<TrustItem
|
||||
icon={GraduationCapIcon}
|
||||
label="marketing.trustSchools"
|
||||
/>
|
||||
<TrustItem icon={BookOpenIcon} label="marketing.trustClubs" />
|
||||
<TrustItem
|
||||
icon={GlobeIcon}
|
||||
label="marketing.trustOrganizations"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Core Modules Feature Grid */}
|
||||
<div className={'container mx-auto'}>
|
||||
<div className={'py-4 xl:py-8'}>
|
||||
<FeatureShowcase
|
||||
heading={
|
||||
<>
|
||||
<b className="font-medium tracking-tight dark:text-white">
|
||||
The ultimate SaaS Starter Kit
|
||||
<Trans i18nKey={'marketing.featuresHeading'} />
|
||||
</b>
|
||||
.{' '}
|
||||
<span className="text-secondary-foreground/70 block font-normal tracking-tight">
|
||||
Unleash your creativity and build your SaaS faster than ever
|
||||
with Makerkit.
|
||||
<Trans i18nKey={'marketing.featuresSubheading'} />
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
icon={
|
||||
<FeatureShowcaseIconContainer>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
<span>All-in-one solution</span>
|
||||
<LayoutDashboardIcon className="h-4 w-4" />
|
||||
<span>
|
||||
<Trans i18nKey={'marketing.featuresLabel'} />
|
||||
</span>
|
||||
</FeatureShowcaseIconContainer>
|
||||
}
|
||||
>
|
||||
<FeatureGrid>
|
||||
<FeatureCard
|
||||
className={'relative col-span-1 overflow-hidden'}
|
||||
label={'Beautiful Dashboard'}
|
||||
description={`Makerkit provides a beautiful dashboard to manage your SaaS business.`}
|
||||
></FeatureCard>
|
||||
|
||||
<FeatureCard
|
||||
className={'relative col-span-1 w-full overflow-hidden'}
|
||||
label={'Authentication'}
|
||||
description={`Makerkit provides a variety of providers to allow your users to sign in.`}
|
||||
></FeatureCard>
|
||||
|
||||
<FeatureCard
|
||||
className={'relative col-span-1 overflow-hidden'}
|
||||
label={'Multi Tenancy'}
|
||||
description={`Multi tenant memberships for your SaaS business.`}
|
||||
<div className="mt-2 grid w-full grid-cols-1 gap-4 md:mt-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<IconFeatureCard
|
||||
icon={UsersIcon}
|
||||
titleKey="marketing.featureMembersTitle"
|
||||
descKey="marketing.featureMembersDesc"
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
className={'relative col-span-1 overflow-hidden'}
|
||||
label={'Billing'}
|
||||
description={`Makerkit supports multiple payment gateways to charge your customers.`}
|
||||
<IconFeatureCard
|
||||
icon={GraduationCapIcon}
|
||||
titleKey="marketing.featureCoursesTitle"
|
||||
descKey="marketing.featureCoursesDesc"
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
className={'relative col-span-1 overflow-hidden'}
|
||||
label={'Plugins'}
|
||||
description={`Extend your SaaS with plugins that you can install using the CLI.`}
|
||||
<IconFeatureCard
|
||||
icon={BedDoubleIcon}
|
||||
titleKey="marketing.featureBookingsTitle"
|
||||
descKey="marketing.featureBookingsDesc"
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
className={'relative col-span-1 overflow-hidden'}
|
||||
label={'Documentation'}
|
||||
description={`Makerkit provides a comprehensive documentation to help you get started.`}
|
||||
<IconFeatureCard
|
||||
icon={CalendarIcon}
|
||||
titleKey="marketing.featureEventsTitle"
|
||||
descKey="marketing.featureEventsDesc"
|
||||
/>
|
||||
</FeatureGrid>
|
||||
<IconFeatureCard
|
||||
icon={WalletIcon}
|
||||
titleKey="marketing.featureFinanceTitle"
|
||||
descKey="marketing.featureFinanceDesc"
|
||||
/>
|
||||
<IconFeatureCard
|
||||
icon={MailIcon}
|
||||
titleKey="marketing.featureNewsletterTitle"
|
||||
descKey="marketing.featureNewsletterDesc"
|
||||
/>
|
||||
</div>
|
||||
</FeatureShowcase>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Showcase */}
|
||||
<div className={'container mx-auto'}>
|
||||
<EcosystemShowcase
|
||||
heading="The ultimate SaaS Starter Kit for founders."
|
||||
description="Unleash your creativity and build your SaaS faster than ever with Makerkit. Get started in minutes and ship your SaaS in no time."
|
||||
heading={<Trans i18nKey={'marketing.showcaseHeading'} />}
|
||||
description={<Trans i18nKey={'marketing.showcaseDescription'} />}
|
||||
>
|
||||
<Image
|
||||
className="rounded-md"
|
||||
src={'/images/sign-in.webp'}
|
||||
alt="Sign in"
|
||||
width={1000}
|
||||
height={1000}
|
||||
className="rounded-lg shadow-lg"
|
||||
src={'/images/dashboard.webp'}
|
||||
alt="MyEasyCMS Dashboard"
|
||||
width={1200}
|
||||
height={800}
|
||||
/>
|
||||
</EcosystemShowcase>
|
||||
</div>
|
||||
|
||||
{/* Additional Features Row */}
|
||||
<div className={'container mx-auto'}>
|
||||
<div className={'py-4 xl:py-8'}>
|
||||
<FeatureShowcase
|
||||
heading={
|
||||
<>
|
||||
<b className="font-medium tracking-tight dark:text-white">
|
||||
<Trans i18nKey={'marketing.additionalFeaturesHeading'} />
|
||||
</b>
|
||||
.{' '}
|
||||
<span className="text-secondary-foreground/70 block font-normal tracking-tight">
|
||||
<Trans
|
||||
i18nKey={'marketing.additionalFeaturesSubheading'}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
icon={
|
||||
<FeatureShowcaseIconContainer>
|
||||
<ZapIcon className="h-4 w-4" />
|
||||
<span>
|
||||
<Trans i18nKey={'marketing.additionalFeaturesLabel'} />
|
||||
</span>
|
||||
</FeatureShowcaseIconContainer>
|
||||
}
|
||||
>
|
||||
<div className="mt-2 grid w-full grid-cols-1 gap-4 md:mt-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<IconFeatureCard
|
||||
icon={FileTextIcon}
|
||||
titleKey="marketing.featureDocumentsTitle"
|
||||
descKey="marketing.featureDocumentsDesc"
|
||||
/>
|
||||
<IconFeatureCard
|
||||
icon={GlobeIcon}
|
||||
titleKey="marketing.featureSiteBuilderTitle"
|
||||
descKey="marketing.featureSiteBuilderDesc"
|
||||
/>
|
||||
<IconFeatureCard
|
||||
icon={LayoutDashboardIcon}
|
||||
titleKey="marketing.featureModulesTitle"
|
||||
descKey="marketing.featureModulesDesc"
|
||||
/>
|
||||
</div>
|
||||
</FeatureShowcase>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Why Choose Us Section */}
|
||||
<div className={'container mx-auto'}>
|
||||
<EcosystemShowcase
|
||||
heading={<Trans i18nKey={'marketing.whyChooseHeading'} />}
|
||||
description={<Trans i18nKey={'marketing.whyChooseDescription'} />}
|
||||
textPosition="right"
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<WhyItem
|
||||
icon={SmartphoneIcon}
|
||||
titleKey="marketing.whyResponsiveTitle"
|
||||
descKey="marketing.whyResponsiveDesc"
|
||||
/>
|
||||
<WhyItem
|
||||
icon={LockIcon}
|
||||
titleKey="marketing.whySecureTitle"
|
||||
descKey="marketing.whySecureDesc"
|
||||
/>
|
||||
<WhyItem
|
||||
icon={HeadsetIcon}
|
||||
titleKey="marketing.whySupportTitle"
|
||||
descKey="marketing.whySupportDesc"
|
||||
/>
|
||||
<WhyItem
|
||||
icon={ShieldCheckIcon}
|
||||
titleKey="marketing.whyGdprTitle"
|
||||
descKey="marketing.whyGdprDesc"
|
||||
/>
|
||||
</div>
|
||||
</EcosystemShowcase>
|
||||
</div>
|
||||
|
||||
{/* How It Works */}
|
||||
<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">
|
||||
<Trans i18nKey={'marketing.howItWorksHeading'} />
|
||||
</h2>
|
||||
<p className="text-secondary-foreground/70 mx-auto mt-4 max-w-2xl text-xl font-medium tracking-tight">
|
||||
<Trans i18nKey={'marketing.howItWorksSubheading'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full grid-cols-1 gap-8 md:grid-cols-3">
|
||||
<StepCard
|
||||
step="01"
|
||||
titleKey="marketing.howStep1Title"
|
||||
descKey="marketing.howStep1Desc"
|
||||
/>
|
||||
<StepCard
|
||||
step="02"
|
||||
titleKey="marketing.howStep2Title"
|
||||
descKey="marketing.howStep2Desc"
|
||||
/>
|
||||
<StepCard
|
||||
step="03"
|
||||
titleKey="marketing.howStep3Title"
|
||||
descKey="marketing.howStep3Desc"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing Section */}
|
||||
<div className={'container mx-auto'}>
|
||||
<div
|
||||
className={
|
||||
@@ -151,9 +292,13 @@ function Home() {
|
||||
}
|
||||
>
|
||||
<SecondaryHero
|
||||
pill={<Pill label="Start for free">No credit card required.</Pill>}
|
||||
heading="Fair pricing for all types of businesses"
|
||||
subheading="Get started on our free plan and upgrade when you are ready."
|
||||
pill={
|
||||
<Pill label={<Trans i18nKey={'marketing.pricingPillLabel'} />}>
|
||||
<Trans i18nKey={'marketing.pricingPillText'} />
|
||||
</Pill>
|
||||
}
|
||||
heading={<Trans i18nKey={'marketing.pricingHeading'} />}
|
||||
subheading={<Trans i18nKey={'marketing.pricingSubheading'} />}
|
||||
/>
|
||||
|
||||
<div className={'w-full'}>
|
||||
@@ -167,6 +312,37 @@ function Home() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<Trans i18nKey={'marketing.ctaHeading'} />
|
||||
</h2>
|
||||
<p className="text-secondary-foreground/70 max-w-2xl text-lg">
|
||||
<Trans i18nKey={'marketing.ctaDescription'} />
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<CtaButton className="h-12 px-8 text-base">
|
||||
<Link href={'/auth/sign-up'}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Trans i18nKey={'marketing.ctaButtonPrimary'} />
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
</span>
|
||||
</Link>
|
||||
</CtaButton>
|
||||
<CtaButton variant={'outline'} className="h-12 px-8 text-base">
|
||||
<Link href={'/contact'}>
|
||||
<Trans i18nKey={'marketing.ctaButtonSecondary'} />
|
||||
</Link>
|
||||
</CtaButton>
|
||||
</div>
|
||||
<p className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
<Trans i18nKey={'marketing.ctaNote'} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -201,3 +377,73 @@ function MainCallToActionButton() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconFeatureCard(props: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
titleKey: string;
|
||||
descKey: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-muted/50 flex flex-col gap-3 rounded p-6">
|
||||
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<props.icon className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<h4 className="text-lg font-medium">
|
||||
<Trans i18nKey={props.titleKey} />
|
||||
</h4>
|
||||
<p className="text-muted-foreground max-w-xs text-sm">
|
||||
<Trans i18nKey={props.descKey} />
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrustItem(props: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex items-center gap-2.5">
|
||||
<props.icon className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">
|
||||
<Trans i18nKey={props.label} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WhyItem(props: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
titleKey: string;
|
||||
descKey: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<div className="bg-primary/10 flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
|
||||
<props.icon className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-secondary-foreground font-medium">
|
||||
<Trans i18nKey={props.titleKey} />
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<Trans i18nKey={props.descKey} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepCard(props: { step: string; titleKey: string; descKey: string }) {
|
||||
return (
|
||||
<div className="bg-muted/50 relative flex flex-col gap-4 rounded-lg p-6">
|
||||
<span className="text-primary/20 text-6xl font-bold">{props.step}</span>
|
||||
<h3 className="text-secondary-foreground text-xl font-medium">
|
||||
<Trans i18nKey={props.titleKey} />
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans i18nKey={props.descKey} />
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
31
apps/web/app/[locale]/club/[slug]/[...page]/page.tsx
Normal file
31
apps/web/app/[locale]/club/[slug]/[...page]/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { SiteRenderer } from '@kit/site-builder/components';
|
||||
|
||||
interface Props { params: Promise<{ slug: string; page: string[] }> }
|
||||
|
||||
export default async function ClubSubPage({ params }: Props) {
|
||||
const { slug, page: pagePath } = await params;
|
||||
const pageSlug = pagePath.join('/');
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
||||
);
|
||||
|
||||
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();
|
||||
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();
|
||||
if (!sitePageData) notFound();
|
||||
|
||||
return (
|
||||
<div style={{ '--primary': settings.primary_color, fontFamily: settings.font_family } as React.CSSProperties}>
|
||||
<SiteRenderer data={(sitePageData.puck_data ?? {}) as Record<string, unknown>} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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 }> }
|
||||
|
||||
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">
|
||||
<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>
|
||||
<CardTitle>Newsletter abonnieren</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Bleiben Sie über Neuigkeiten informiert.</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Name (optional)</Label>
|
||||
<Input name="name" placeholder="Max Mustermann" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>E-Mail-Adresse *</Label>
|
||||
<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.
|
||||
</p>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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 }> }
|
||||
|
||||
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">
|
||||
<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>
|
||||
<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-sm text-muted-foreground">Kein gültiger Abmeldelink. Bitte verwenden Sie den Link aus der Newsletter-E-Mail.</p>
|
||||
)}
|
||||
<Link href={`/club/${slug}`}>
|
||||
<Button variant="outline" size="sm">← Zurück zur Website</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
apps/web/app/[locale]/club/[slug]/page.tsx
Normal file
34
apps/web/app/[locale]/club/[slug]/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { SiteRenderer } from '@kit/site-builder/components';
|
||||
|
||||
interface Props { params: Promise<{ slug: string }> }
|
||||
|
||||
export default async function ClubHomePage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
|
||||
// Use anon client for public access
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
||||
);
|
||||
|
||||
// Resolve slug → account
|
||||
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
|
||||
if (!account) notFound();
|
||||
|
||||
// Check site is public
|
||||
const { data: settings } = await supabase.from('site_settings').select('*').eq('account_id', account.id).eq('is_public', true).maybeSingle();
|
||||
if (!settings) notFound();
|
||||
|
||||
// Get homepage
|
||||
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();
|
||||
|
||||
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>} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
apps/web/app/[locale]/club/[slug]/portal/documents/page.tsx
Normal file
100
apps/web/app/[locale]/club/[slug]/portal/documents/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export default async function PortalDocumentsPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
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>;
|
||||
|
||||
// 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' },
|
||||
];
|
||||
|
||||
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>;
|
||||
}
|
||||
};
|
||||
|
||||
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" />;
|
||||
}
|
||||
};
|
||||
|
||||
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="flex items-center gap-3">
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-lg font-bold">Meine Dokumente</h1>
|
||||
</div>
|
||||
<Link href={`/club/${slug}/portal`}>
|
||||
<Button variant="ghost" size="sm">← Zurück zum Portal</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-3xl mx-auto py-8 px-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verfügbare Dokumente</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{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" />
|
||||
<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 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>
|
||||
</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" />
|
||||
PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
apps/web/app/[locale]/club/[slug]/portal/invite/page.tsx
Normal file
125
apps/web/app/[locale]/club/[slug]/portal/invite/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { UserPlus, Shield, CheckCircle } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ token?: string }>;
|
||||
}
|
||||
|
||||
export default async function PortalInvitePage({ params, searchParams }: Props) {
|
||||
const { slug } = await params;
|
||||
const { token } = await searchParams;
|
||||
|
||||
if (!token) notFound();
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
||||
);
|
||||
|
||||
// Resolve account
|
||||
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')
|
||||
.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">
|
||||
<Card className="max-w-md text-center">
|
||||
<CardContent className="p-8">
|
||||
<Shield className="mx-auto h-10 w-10 text-destructive mb-4" />
|
||||
<h2 className="text-lg font-bold">Einladung ungültig</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Diese Einladung ist abgelaufen, wurde bereits verwendet oder ist ungültig.
|
||||
Bitte wenden Sie sich an Ihren Vereinsadministrator.
|
||||
</p>
|
||||
<Link href={`/club/${slug}`}>
|
||||
<Button variant="outline" className="mt-4">← Zur Website</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<Card className="max-w-md text-center">
|
||||
<CardContent className="p-8">
|
||||
<Shield className="mx-auto h-10 w-10 text-amber-500 mb-4" />
|
||||
<h2 className="text-lg font-bold">Einladung abgelaufen</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Diese Einladung ist am {new Date(invitation.expires_at).toLocaleDateString('de-DE')} abgelaufen.
|
||||
Bitte fordern Sie eine neue Einladung an.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<UserPlus className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle>Einladung zum Mitgliederbereich</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{String(account.name)}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md bg-primary/5 border border-primary/20 p-4 mb-6">
|
||||
<p className="text-sm">
|
||||
Sie wurden eingeladen, ein Konto für den Mitgliederbereich zu erstellen.
|
||||
Damit können Sie Ihr Profil einsehen, Dokumente herunterladen und Ihre Datenschutz-Einstellungen verwalten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Passwort festlegen *</Label>
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Konto erstellen & Einladung annehmen
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
apps/web/app/[locale]/club/[slug]/portal/page.tsx
Normal file
100
apps/web/app/[locale]/club/[slug]/portal/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
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 { PortalLoginForm } from '@kit/site-builder/components';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export default async function MemberPortalPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
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>;
|
||||
|
||||
// Check if user is already logged in
|
||||
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')
|
||||
.select('id, first_name, last_name, status')
|
||||
.eq('account_id', account.id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
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="flex items-center gap-3">
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-lg font-bold">Mitgliederbereich — {String(account.name)}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">{String(member.first_name)} {String(member.last_name)}</span>
|
||||
<Link href={`/club/${slug}`}><Button variant="ghost" size="sm">← Website</Button></Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-4xl mx-auto py-12 px-6">
|
||||
<h2 className="text-2xl font-bold mb-6">Willkommen, {String(member.first_name)}!</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Link href={`/club/${slug}/portal/profile`}>
|
||||
<Card className="hover:border-primary transition-colors cursor-pointer">
|
||||
<CardContent className="p-6 text-center">
|
||||
<UserCircle className="mx-auto h-10 w-10 text-primary mb-3" />
|
||||
<h3 className="font-semibold">Mein Profil</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">Kontaktdaten und Datenschutz</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href={`/club/${slug}/portal/documents`}>
|
||||
<Card className="hover:border-primary transition-colors cursor-pointer">
|
||||
<CardContent className="p-6 text-center">
|
||||
<FileText className="mx-auto h-10 w-10 text-primary mb-3" />
|
||||
<h3 className="font-semibold">Dokumente</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">Rechnungen und Bescheinigungen</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<CreditCard className="mx-auto h-10 w-10 text-primary mb-3" />
|
||||
<h3 className="font-semibold">Mitgliedsausweis</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">Digital anzeigen</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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">
|
||||
<h1 className="text-lg font-bold">Mitgliederbereich</h1>
|
||||
<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">
|
||||
<PortalLoginForm slug={slug} accountName={String(account.name)} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
apps/web/app/[locale]/club/[slug]/portal/profile/page.tsx
Normal file
131
apps/web/app/[locale]/club/[slug]/portal/profile/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { UserCircle, Mail, MapPin, Phone, Shield, Calendar } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export default async function PortalProfilePage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
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>;
|
||||
|
||||
// Get current user
|
||||
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')
|
||||
.select('*')
|
||||
.eq('account_id', account.id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!member) {
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/30 flex items-center justify-center">
|
||||
<Card className="max-w-md">
|
||||
<CardContent className="p-8 text-center">
|
||||
<Shield className="mx-auto h-10 w-10 text-destructive mb-4" />
|
||||
<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>
|
||||
<Link href={`/club/${slug}/portal`}>
|
||||
<Button variant="outline" className="mt-4">← Zurück</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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="flex items-center gap-3">
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-lg font-bold">Mein Profil</h1>
|
||||
</div>
|
||||
<Link href={`/club/${slug}/portal`}><Button variant="ghost" size="sm">← Zurück zum Portal</Button></Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-3xl mx-auto py-8 px-6 space-y-6">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<UserCircle className="h-8 w-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{String(m.first_name)} {String(m.last_name)}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nr. {String(m.member_number ?? '—')} — Mitglied seit {m.entry_date ? new Date(String(m.entry_date)).toLocaleDateString('de-DE') : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<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>
|
||||
</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 },
|
||||
].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" />
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button>Änderungen speichern</Button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,235 +1,28 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft, BedDouble } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
|
||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
|
||||
import { CreateBookingForm } from '@kit/booking-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
|
||||
export default async function NewBookingPage({ params }: PageProps) {
|
||||
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 <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createBookingManagementApi(client);
|
||||
|
||||
const [rooms, guests] = await Promise.all([
|
||||
api.listRooms(acct.id),
|
||||
api.listGuests(acct.id),
|
||||
]);
|
||||
const rooms = await api.listRooms(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neue Buchung">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/home/${account}/bookings`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Neue Buchung</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Buchung für ein Zimmer erstellen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-col gap-6">
|
||||
{/* Zimmer & Gast */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BedDouble className="h-5 w-5" />
|
||||
Zimmer & Gast
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Wählen Sie Zimmer und Gast für die Buchung aus
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="room_id"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Zimmer
|
||||
</label>
|
||||
<select
|
||||
id="room_id"
|
||||
name="room_id"
|
||||
required
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">Zimmer wählen…</option>
|
||||
{rooms.map((room: Record<string, unknown>) => (
|
||||
<option key={String(room.id)} value={String(room.id)}>
|
||||
{String(room.room_number)} – {String(room.name ?? room.room_type ?? '')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="guest_id"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Gast
|
||||
</label>
|
||||
<select
|
||||
id="guest_id"
|
||||
name="guest_id"
|
||||
required
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">Gast wählen…</option>
|
||||
{guests.map((guest: Record<string, unknown>) => (
|
||||
<option key={String(guest.id)} value={String(guest.id)}>
|
||||
{String(guest.last_name)}, {String(guest.first_name)}
|
||||
{guest.email ? ` (${String(guest.email)})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Aufenthalt */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Aufenthalt</CardTitle>
|
||||
<CardDescription>
|
||||
Reisedaten und Personenanzahl
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="check_in" className="text-sm font-medium">
|
||||
Anreise
|
||||
</label>
|
||||
<input
|
||||
id="check_in"
|
||||
name="check_in"
|
||||
type="date"
|
||||
required
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="check_out" className="text-sm font-medium">
|
||||
Abreise
|
||||
</label>
|
||||
<input
|
||||
id="check_out"
|
||||
name="check_out"
|
||||
type="date"
|
||||
required
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="adults" className="text-sm font-medium">
|
||||
Erwachsene
|
||||
</label>
|
||||
<input
|
||||
id="adults"
|
||||
name="adults"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={1}
|
||||
required
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="children" className="text-sm font-medium">
|
||||
Kinder
|
||||
</label>
|
||||
<input
|
||||
id="children"
|
||||
name="children"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={0}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Preis & Notizen */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preis & Notizen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5 md:w-1/2">
|
||||
<label htmlFor="total_price" className="text-sm font-medium">
|
||||
Preis (€)
|
||||
</label>
|
||||
<input
|
||||
id="total_price"
|
||||
name="total_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
placeholder="0.00"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="notes" className="text-sm font-medium">
|
||||
Notizen
|
||||
</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
rows={3}
|
||||
placeholder="Zusätzliche Informationen zur Buchung…"
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Link href={`/home/${account}/bookings`}>
|
||||
<Button type="button" variant="outline">
|
||||
Abbrechen
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit">Buchung erstellen</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<CmsPageShell 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)
|
||||
}))}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,191 +1,18 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { Textarea } from '@kit/ui/textarea';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { CreateCourseForm } from '@kit/course-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
|
||||
export default async function NewCoursePage({ params }: PageProps) {
|
||||
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();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neuer Kurs">
|
||||
<div className="flex w-full max-w-3xl flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/home/${account}/courses`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Zurück
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Neuer Kurs</h1>
|
||||
<p className="text-muted-foreground">Kurs anlegen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-col gap-6">
|
||||
{/* Grunddaten */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Grunddaten</CardTitle>
|
||||
<CardDescription>
|
||||
Allgemeine Informationen zum Kurs
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="courseNumber">Kursnummer</Label>
|
||||
<Input
|
||||
id="courseNumber"
|
||||
name="courseNumber"
|
||||
placeholder="z.B. K-2025-001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Kursname</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="z.B. Töpfern für Anfänger"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Beschreibung</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Kursbeschreibung…"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Zeitplan */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Zeitplan</CardTitle>
|
||||
<CardDescription>Beginn und Ende des Kurses</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="startDate">Beginn</Label>
|
||||
<Input
|
||||
id="startDate"
|
||||
name="startDate"
|
||||
type="date"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="endDate">Ende</Label>
|
||||
<Input id="endDate" name="endDate" type="date" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Kapazität */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kapazität</CardTitle>
|
||||
<CardDescription>
|
||||
Teilnehmer und Gebühren
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="capacity">Max. Teilnehmer</Label>
|
||||
<Input
|
||||
id="capacity"
|
||||
name="capacity"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="minParticipants">Min. Teilnehmer</Label>
|
||||
<Input
|
||||
id="minParticipants"
|
||||
name="minParticipants"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="fee">Gebühr (€)</Label>
|
||||
<Input
|
||||
id="fee"
|
||||
name="fee"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Zuordnung */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Zuordnung</CardTitle>
|
||||
<CardDescription>Status des Kurses</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
defaultValue="planned"
|
||||
>
|
||||
<option value="planned">Geplant</option>
|
||||
<option value="open">Offen</option>
|
||||
<option value="running">Laufend</option>
|
||||
<option value="completed">Abgeschlossen</option>
|
||||
<option value="cancelled">Abgesagt</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Link href={`/home/${account}/courses`}>
|
||||
<Button variant="outline" type="button">
|
||||
Abbrechen
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit">Kurs erstellen</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<CmsPageShell account={account} title="Neuer Kurs" description="Kurs anlegen">
|
||||
<CreateCourseForm accountId={acct.id} account={account} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
GraduationCap,
|
||||
Users,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
BarChart3,
|
||||
} from 'lucide-react';
|
||||
import { GraduationCap, Users, Calendar, TrendingUp, BarChart3 } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
@@ -13,6 +7,7 @@ import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
import { StatsBarChart, StatsPieChart } from '~/components/stats-charts';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -22,77 +17,53 @@ 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 <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createCourseManagementApi(client);
|
||||
const stats = await api.getStatistics(acct.id);
|
||||
|
||||
const statusChartData = [
|
||||
{ name: 'Aktiv', value: stats.openCourses },
|
||||
{ name: 'Abgeschlossen', value: stats.completedCourses },
|
||||
{ name: 'Gesamt', value: stats.totalCourses },
|
||||
];
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Kurs-Statistiken">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Statistiken</h1>
|
||||
<p className="text-muted-foreground">Übersicht über das Kursangebot</p>
|
||||
</div>
|
||||
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title="Kurse gesamt"
|
||||
value={stats.totalCourses}
|
||||
icon={<GraduationCap className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Aktive Kurse"
|
||||
value={stats.openCourses}
|
||||
icon={<Calendar className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Teilnehmer gesamt"
|
||||
value={stats.totalParticipants}
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Abgeschlossen"
|
||||
value={stats.completedCourses}
|
||||
icon={<TrendingUp className="h-5 w-5" />}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* Chart Placeholder */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
Kursauslastung
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-64 items-center justify-center rounded-md border border-dashed text-muted-foreground">
|
||||
Diagramm wird hier angezeigt
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
Kursauslastung
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatsBarChart data={statusChartData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
Anmeldungen pro Monat
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-64 items-center justify-center rounded-md border border-dashed text-muted-foreground">
|
||||
Diagramm wird hier angezeigt
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
Verteilung
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatsPieChart data={statusChartData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
|
||||
@@ -1,301 +1,18 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
MapPin,
|
||||
Phone,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
|
||||
import { CreateEventForm } from '@kit/event-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
|
||||
export default async function NewEventPage({ params }: PageProps) {
|
||||
export default async function NewEventPage({ 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 <div>Konto nicht gefunden</div>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neue Veranstaltung">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/home/${account}/events`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Neue Veranstaltung</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Veranstaltung oder Ferienprogramm anlegen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-col gap-6">
|
||||
{/* Grunddaten */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CalendarDays className="h-5 w-5" />
|
||||
Grunddaten
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Name und Beschreibung der Veranstaltung
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="name" className="text-sm font-medium">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="z.B. Sommerfest 2025"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="description" className="text-sm font-medium">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows={4}
|
||||
placeholder="Beschreiben Sie die Veranstaltung…"
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Datum & Ort */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" />
|
||||
Datum & Ort
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Zeitraum und Veranstaltungsort
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="event_date" className="text-sm font-medium">
|
||||
Veranstaltungsdatum
|
||||
</label>
|
||||
<input
|
||||
id="event_date"
|
||||
name="event_date"
|
||||
type="date"
|
||||
required
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="event_time" className="text-sm font-medium">
|
||||
Uhrzeit
|
||||
</label>
|
||||
<input
|
||||
id="event_time"
|
||||
name="event_time"
|
||||
type="time"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="end_date" className="text-sm font-medium">
|
||||
Enddatum
|
||||
</label>
|
||||
<input
|
||||
id="end_date"
|
||||
name="end_date"
|
||||
type="date"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="location" className="text-sm font-medium">
|
||||
Ort
|
||||
</label>
|
||||
<input
|
||||
id="location"
|
||||
name="location"
|
||||
type="text"
|
||||
placeholder="z.B. Gemeindehaus, Turnhalle"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Teilnehmer & Kosten */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Teilnehmer & Kosten
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Kapazität, Alter und Teilnahmegebühr
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="capacity" className="text-sm font-medium">
|
||||
Kapazität
|
||||
</label>
|
||||
<input
|
||||
id="capacity"
|
||||
name="capacity"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Max. Teilnehmer"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="min_age" className="text-sm font-medium">
|
||||
Mindestalter
|
||||
</label>
|
||||
<input
|
||||
id="min_age"
|
||||
name="min_age"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="z.B. 6"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="max_age" className="text-sm font-medium">
|
||||
Höchstalter
|
||||
</label>
|
||||
<input
|
||||
id="max_age"
|
||||
name="max_age"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="z.B. 16"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="fee" className="text-sm font-medium">
|
||||
Gebühr (€)
|
||||
</label>
|
||||
<input
|
||||
id="fee"
|
||||
name="fee"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
placeholder="0.00"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Kontakt */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Phone className="h-5 w-5" />
|
||||
Kontakt
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Ansprechpartner für die Veranstaltung
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="contact_name" className="text-sm font-medium">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="contact_name"
|
||||
name="contact_name"
|
||||
type="text"
|
||||
placeholder="Vorname Nachname"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="contact_email" className="text-sm font-medium">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="contact_email"
|
||||
name="contact_email"
|
||||
type="email"
|
||||
placeholder="name@example.de"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="contact_phone" className="text-sm font-medium">
|
||||
Telefon
|
||||
</label>
|
||||
<input
|
||||
id="contact_phone"
|
||||
name="contact_phone"
|
||||
type="tel"
|
||||
placeholder="+49 …"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Link href={`/home/${account}/events`}>
|
||||
<Button type="button" variant="outline">
|
||||
Abbrechen
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit">Veranstaltung erstellen</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<CmsPageShell account={account} title="Neue Veranstaltung" description="Veranstaltung oder Ferienprogramm anlegen">
|
||||
<CreateEventForm accountId={acct.id} account={account} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,212 +1,18 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { Textarea } from '@kit/ui/textarea';
|
||||
|
||||
import { CreateInvoiceForm } from '@kit/finance/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
|
||||
export default async function NewInvoicePage({ params }: PageProps) {
|
||||
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 <div>Konto nicht gefunden</div>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neue Rechnung">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Back link */}
|
||||
<div>
|
||||
<Link
|
||||
href={`/home/${account}/finance/invoices`}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Zurück zu Rechnungen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-3xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Neue Rechnung</CardTitle>
|
||||
<CardDescription>
|
||||
Erstellen Sie eine neue Rechnung mit Positionen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-6">
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="invoiceNumber">Rechnungsnummer</Label>
|
||||
<Input
|
||||
id="invoiceNumber"
|
||||
name="invoiceNumber"
|
||||
placeholder="RE-2026-001"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="recipientName">Empfänger</Label>
|
||||
<Input
|
||||
id="recipientName"
|
||||
name="recipientName"
|
||||
placeholder="Max Mustermann"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recipient Address */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="recipientAddress">Empfängeradresse</Label>
|
||||
<Textarea
|
||||
id="recipientAddress"
|
||||
name="recipientAddress"
|
||||
placeholder="Musterstraße 1 12345 Musterstadt"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dates + Tax */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="issueDate">Rechnungsdatum</Label>
|
||||
<Input
|
||||
id="issueDate"
|
||||
name="issueDate"
|
||||
type="date"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="dueDate">Fälligkeitsdatum</Label>
|
||||
<Input
|
||||
id="dueDate"
|
||||
name="dueDate"
|
||||
type="date"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="taxRate">Steuersatz (%)</Label>
|
||||
<Input
|
||||
id="taxRate"
|
||||
name="taxRate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
defaultValue="19"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line Items */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>Positionen</Label>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">
|
||||
Beschreibung
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">Menge</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
Einzelpreis (€)
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">Gesamt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[0, 1, 2].map((idx) => (
|
||||
<tr key={idx} className="border-b">
|
||||
<td className="p-2">
|
||||
<Input
|
||||
name={`items[${idx}].description`}
|
||||
placeholder="Leistungsbeschreibung"
|
||||
className="border-0 shadow-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="w-24 p-2">
|
||||
<Input
|
||||
name={`items[${idx}].quantity`}
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
defaultValue="1"
|
||||
className="border-0 text-right shadow-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="w-32 p-2">
|
||||
<Input
|
||||
name={`items[${idx}].unitPrice`}
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
defaultValue="0.00"
|
||||
className="border-0 text-right shadow-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="w-32 p-3 text-right text-muted-foreground">
|
||||
0,00 €
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="ml-auto flex w-64 flex-col gap-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Zwischensumme</span>
|
||||
<span>0,00 €</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">MwSt. (19%)</span>
|
||||
<span>0,00 €</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t pt-1 font-semibold">
|
||||
<span>Gesamt</span>
|
||||
<span>0,00 €</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-between">
|
||||
<Link href={`/home/${account}/finance/invoices`}>
|
||||
<Button variant="outline">Abbrechen</Button>
|
||||
</Link>
|
||||
<Button type="submit">Rechnung erstellen</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
<CmsPageShell account={account} title="Neue Rechnung" description="Rechnung mit Positionen erstellen">
|
||||
<CreateInvoiceForm accountId={acct.id} account={account} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,13 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
|
||||
import { CreateSepaBatchForm } from '@kit/finance/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewSepaPage({ params }: PageProps) {
|
||||
export default async function NewSepaBatchPage({ params }: Props) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
@@ -34,84 +20,8 @@ export default async function NewSepaPage({ params }: PageProps) {
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neuer SEPA-Einzug">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Back link */}
|
||||
<div>
|
||||
<Link
|
||||
href={`/home/${account}/finance/sepa`}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Zurück zu SEPA-Lastschriften
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Neuer SEPA-Einzug</CardTitle>
|
||||
<CardDescription>
|
||||
Erstellen Sie einen neuen Lastschrifteinzug oder eine Überweisung.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-5">
|
||||
{/* Typ */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="batchType">Typ</Label>
|
||||
<select
|
||||
id="batchType"
|
||||
name="batchType"
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
defaultValue="direct_debit"
|
||||
>
|
||||
<option value="direct_debit">Lastschrift</option>
|
||||
<option value="credit_transfer">Überweisung</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Beschreibung */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="description">Beschreibung</Label>
|
||||
<Input
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="z.B. Mitgliedsbeiträge Q1 2026"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ausführungsdatum */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="executionDate">Ausführungsdatum</Label>
|
||||
<Input
|
||||
id="executionDate"
|
||||
name="executionDate"
|
||||
type="date"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* PAIN Format Info */}
|
||||
<div className="rounded-md bg-muted/50 p-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<strong>Hinweis:</strong> Nach dem Erstellen können Sie
|
||||
einzelne Positionen hinzufügen und anschließend die
|
||||
SEPA-XML-Datei generieren.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-between">
|
||||
<Link href={`/home/${account}/finance/sepa`}>
|
||||
<Button variant="outline">Abbrechen</Button>
|
||||
</Link>
|
||||
<Button type="submit">Einzug erstellen</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
<CmsPageShell account={account} title="Neuer SEPA-Einzug" description="SEPA-Lastschrifteinzug erstellen">
|
||||
<CreateSepaBatchForm accountId={acct.id} account={account} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string; memberId: string }>;
|
||||
}
|
||||
|
||||
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();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const member = await api.getMember(memberId);
|
||||
if (!member) return <div>Mitglied nicht gefunden</div>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={`${String(member.first_name)} ${String(member.last_name)} bearbeiten`}>
|
||||
<EditMemberForm member={member} account={account} accountId={acct.id} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,173 +1,25 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { User, Mail, Phone, MapPin, CreditCard, Pencil, Ban } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
|
||||
import { MemberDetailView } from '@kit/member-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
interface Props {
|
||||
params: Promise<{ account: string; memberId: string }>;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
active: 'Aktiv',
|
||||
inactive: 'Inaktiv',
|
||||
pending: 'Ausstehend',
|
||||
cancelled: 'Gekündigt',
|
||||
};
|
||||
|
||||
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
active: 'default',
|
||||
inactive: 'secondary',
|
||||
pending: 'outline',
|
||||
cancelled: 'destructive',
|
||||
};
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-medium">{value || '—'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function MemberDetailPage({ params }: PageProps) {
|
||||
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();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
|
||||
const member = await api.getMember(memberId);
|
||||
|
||||
if (!member) return <div>Mitglied nicht gefunden</div>;
|
||||
|
||||
const m = member as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={`${String(m.first_name)} ${String(m.last_name)}`}>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{String(m.first_name)} {String(m.last_name)}
|
||||
</h1>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Badge variant={STATUS_VARIANT[String(m.status)] ?? 'secondary'}>
|
||||
{STATUS_LABEL[String(m.status)] ?? String(m.status)}
|
||||
</Badge>
|
||||
{m.member_number ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Nr. {String(m.member_number)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline">
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button variant="destructive">
|
||||
<Ban className="mr-2 h-4 w-4" />
|
||||
Kündigen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Persönliche Daten */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
Persönliche Daten
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-4">
|
||||
<DetailRow label="Vorname" value={String(m.first_name ?? '')} />
|
||||
<DetailRow label="Nachname" value={String(m.last_name ?? '')} />
|
||||
<DetailRow
|
||||
label="Geburtsdatum"
|
||||
value={m.date_of_birth ? new Date(String(m.date_of_birth)).toLocaleDateString('de-DE') : ''}
|
||||
/>
|
||||
<DetailRow label="Geschlecht" value={String(m.gender ?? '')} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Kontakt */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
Kontakt
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-4">
|
||||
<DetailRow label="E-Mail" value={String(m.email ?? '')} />
|
||||
<DetailRow label="Telefon" value={String(m.phone ?? '')} />
|
||||
<DetailRow label="Mobil" value={String(m.mobile ?? '')} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Adresse */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
Adresse
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-4">
|
||||
<DetailRow label="Straße" value={`${String(m.street ?? '')} ${String(m.house_number ?? '')}`} />
|
||||
<DetailRow label="PLZ / Ort" value={`${String(m.postal_code ?? '')} ${String(m.city ?? '')}`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Mitgliedschaft */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
Mitgliedschaft
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-4">
|
||||
<DetailRow label="Mitgliedsnr." value={String(m.member_number ?? '')} />
|
||||
<DetailRow label="Status" value={STATUS_LABEL[String(m.status)] ?? String(m.status ?? '')} />
|
||||
<DetailRow
|
||||
label="Eintrittsdatum"
|
||||
value={m.entry_date ? new Date(String(m.entry_date)).toLocaleDateString('de-DE') : ''}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Austrittsdatum"
|
||||
value={m.exit_date ? new Date(String(m.exit_date)).toLocaleDateString('de-DE') : ''}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SEPA */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CreditCard className="h-4 w-4" />
|
||||
SEPA-Bankverbindung
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<DetailRow label="IBAN" value={String(m.iban ?? '')} />
|
||||
<DetailRow label="BIC" value={String(m.bic ?? '')} />
|
||||
<DetailRow label="Kontoinhaber" value={String(m.account_holder ?? '')} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<CmsPageShell account={account} title={`${String(member.first_name)} ${String(member.last_name)}`}>
|
||||
<MemberDetailView member={member} account={account} accountId={acct.id} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,117 +1,24 @@
|
||||
import { UserCheck, UserX, FileText } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
|
||||
import { ApplicationWorkflow } from '@kit/member-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
interface PageProps {
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'destructive'> = {
|
||||
pending: 'secondary',
|
||||
approved: 'default',
|
||||
rejected: 'destructive',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: 'Ausstehend',
|
||||
approved: 'Genehmigt',
|
||||
rejected: 'Abgelehnt',
|
||||
};
|
||||
|
||||
export default async function ApplicationsPage({ params }: PageProps) {
|
||||
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 <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const applications = await api.listApplications(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Anträge">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Mitgliedsanträge</h1>
|
||||
<p className="text-muted-foreground">Eingehende Anträge prüfen und bearbeiten</p>
|
||||
</div>
|
||||
|
||||
{applications.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FileText className="h-8 w-8" />}
|
||||
title="Keine Anträge"
|
||||
description="Es liegen derzeit keine Mitgliedsanträge vor."
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Anträge ({applications.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
||||
<th className="p-3 text-left font-medium">Datum</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-right font-medium">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{applications.map((app: Record<string, unknown>) => (
|
||||
<tr key={String(app.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">
|
||||
{String(app.last_name ?? '')}, {String(app.first_name ?? '')}
|
||||
</td>
|
||||
<td className="p-3">{String(app.email ?? '—')}</td>
|
||||
<td className="p-3">
|
||||
{app.created_at
|
||||
? new Date(String(app.created_at)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={STATUS_VARIANT[String(app.status)] ?? 'secondary'}>
|
||||
{STATUS_LABEL[String(app.status)] ?? String(app.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{String(app.status) === 'pending' && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="default">
|
||||
<UserCheck className="mr-1 h-3 w-3" />
|
||||
Genehmigen
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive">
|
||||
<UserX className="mr-1 h-3 w-3" />
|
||||
Ablehnen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<CmsPageShell account={account} title="Aufnahmeanträge" description="Mitgliedsanträge bearbeiten">
|
||||
<ApplicationWorkflow applications={applications} accountId={acct.id} account={account} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { CreditCard, Download } from 'lucide-react';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
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();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const result = await api.listMembers(acct.id, { status: 'active', pageSize: 100 });
|
||||
const members = result.data;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Mitgliedsausweise" description="Ausweise erstellen und verwalten">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{members.length} aktive Mitglieder</p>
|
||||
<Button disabled>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Alle Ausweise generieren (PDF)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{members.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<CreditCard className="h-8 w-8" />}
|
||||
title="Keine aktiven Mitglieder"
|
||||
description="Erstellen Sie zuerst Mitglieder, um Ausweise zu generieren."
|
||||
actionLabel="Mitglieder verwalten"
|
||||
actionHref={`/home/${account}/members-cms`}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{members.map((m: Record<string, unknown>) => (
|
||||
<Card key={String(m.id)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold">{String(m.last_name)}, {String(m.first_name)}</p>
|
||||
<p className="text-xs text-muted-foreground">Nr. {String(m.member_number ?? '—')}</p>
|
||||
</div>
|
||||
<Badge variant="default">Aktiv</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<CreditCard className="mr-1 h-3 w-3" />
|
||||
Ausweis
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>PDF-Generierung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Die PDF-Generierung erfordert die Installation von <code>@react-pdf/renderer</code>.
|
||||
Nach der Installation können Mitgliedsausweise einzeln oder als Stapel erstellt werden.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
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();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const departments = await api.listDepartments(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Abteilungen" description="Sparten und Abteilungen verwalten">
|
||||
{departments.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Users className="h-8 w-8" />}
|
||||
title="Keine Abteilungen vorhanden"
|
||||
description="Erstellen Sie Ihre erste Abteilung."
|
||||
actionLabel="Neue Abteilung"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{departments.map((dept: Record<string, unknown>) => (
|
||||
<tr key={String(dept.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">{String(dept.name)}</td>
|
||||
<td className="p-3 text-muted-foreground">{String(dept.description ?? '—')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +1,24 @@
|
||||
import { Euro, Plus } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
|
||||
import { DuesCategoryManager } from '@kit/member-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
interface PageProps {
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function DuesPage({ params }: PageProps) {
|
||||
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 <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const categories = await api.listDuesCategories(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Beitragskategorien">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Beitragskategorien</h1>
|
||||
<p className="text-muted-foreground">Beiträge und Gebühren verwalten</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neue Kategorie
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{categories.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Euro className="h-8 w-8" />}
|
||||
title="Keine Beitragskategorien"
|
||||
description="Legen Sie Ihre erste Beitragskategorie an."
|
||||
actionLabel="Neue Kategorie"
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Kategorien ({categories.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">Beschreibung</th>
|
||||
<th className="p-3 text-right font-medium">Betrag (€)</th>
|
||||
<th className="p-3 text-left font-medium">Intervall</th>
|
||||
<th className="p-3 text-center font-medium">Standard</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.map((cat: Record<string, unknown>) => (
|
||||
<tr key={String(cat.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">{String(cat.name)}</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{String(cat.description ?? '—')}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{cat.amount != null ? `${Number(cat.amount).toFixed(2)}` : '—'}
|
||||
</td>
|
||||
<td className="p-3">{String(cat.interval ?? '—')}</td>
|
||||
<td className="p-3 text-center">
|
||||
{cat.is_default ? '✓' : '✗'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<CmsPageShell account={account} title="Beitragskategorien" description="Mitgliedsbeiträge verwalten">
|
||||
<DuesCategoryManager categories={categories} accountId={acct.id} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { MemberImportWizard } from '@kit/member-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
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();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Mitglieder importieren" description="CSV-Datei importieren">
|
||||
<MemberImportWizard accountId={acct.id} account={account} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +1,28 @@
|
||||
import { UserPlus } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { CreateMemberForm } from '@kit/member-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
|
||||
export default async function NewMemberPage({ params }: PageProps) {
|
||||
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();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const duesCategories = await api.listDuesCategories(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neues Mitglied">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Neues Mitglied</h1>
|
||||
<p className="text-muted-foreground">Mitglied manuell anlegen</p>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-col gap-6">
|
||||
{/* Persönliche Daten */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Persönliche Daten</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">Vorname</Label>
|
||||
<Input id="firstName" name="firstName" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">Nachname</Label>
|
||||
<Input id="lastName" name="lastName" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dateOfBirth">Geburtsdatum</Label>
|
||||
<Input id="dateOfBirth" name="dateOfBirth" type="date" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gender">Geschlecht</Label>
|
||||
<select
|
||||
id="gender"
|
||||
name="gender"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">— Bitte wählen —</option>
|
||||
<option value="male">Männlich</option>
|
||||
<option value="female">Weiblich</option>
|
||||
<option value="diverse">Divers</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Kontakt */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kontakt</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-Mail</Label>
|
||||
<Input id="email" name="email" type="email" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Telefon</Label>
|
||||
<Input id="phone" name="phone" type="tel" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mobile">Mobil</Label>
|
||||
<Input id="mobile" name="mobile" type="tel" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Adresse */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Adresse</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2 sm:col-span-1">
|
||||
<Label htmlFor="street">Straße</Label>
|
||||
<Input id="street" name="street" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="houseNumber">Hausnummer</Label>
|
||||
<Input id="houseNumber" name="houseNumber" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postalCode">PLZ</Label>
|
||||
<Input id="postalCode" name="postalCode" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Input id="city" name="city" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Mitgliedschaft */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mitgliedschaft</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="memberNumber">Mitgliedsnr.</Label>
|
||||
<Input id="memberNumber" name="memberNumber" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="active">Aktiv</option>
|
||||
<option value="inactive">Inaktiv</option>
|
||||
<option value="pending">Ausstehend</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entryDate">Eintrittsdatum</Label>
|
||||
<Input id="entryDate" name="entryDate" type="date" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SEPA */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>SEPA-Bankverbindung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="iban">IBAN</Label>
|
||||
<Input id="iban" name="iban" placeholder="DE89 3704 0044 0532 0130 00" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bic">BIC</Label>
|
||||
<Input id="bic" name="bic" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accountHolder">Kontoinhaber</Label>
|
||||
<Input id="accountHolder" name="accountHolder" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notizen */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notizen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<textarea
|
||||
name="notes"
|
||||
rows={4}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder="Zusätzliche Anmerkungen…"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" size="lg">
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Mitglied erstellen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<CmsPageShell account={account} title="Neues Mitglied" description="Mitglied manuell anlegen">
|
||||
<CreateMemberForm
|
||||
accountId={acct.id}
|
||||
account={account}
|
||||
duesCategories={(duesCategories ?? []).map((c: Record<string, unknown>) => ({
|
||||
id: String(c.id), name: String(c.name), amount: Number(c.amount ?? 0)
|
||||
}))}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,66 +1,42 @@
|
||||
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';
|
||||
|
||||
interface MembersPageProps {
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function MembersPage({ params, searchParams }: MembersPageProps) {
|
||||
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();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
|
||||
const { data: accountData } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!accountData) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const page = Number(search.page) || 1;
|
||||
const result = await api.listMembers(accountData.id, {
|
||||
const result = await api.listMembers(acct.id, {
|
||||
search: search.q as string,
|
||||
status: search.status as string,
|
||||
page,
|
||||
pageSize: 25,
|
||||
});
|
||||
const duesCategories = await api.listDuesCategories(acct.id);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Mitglieder</h1>
|
||||
<p className="text-muted-foreground">{result.total} Mitglieder</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left">Nr.</th>
|
||||
<th className="p-3 text-left">Name</th>
|
||||
<th className="p-3 text-left">E-Mail</th>
|
||||
<th className="p-3 text-left">Ort</th>
|
||||
<th className="p-3 text-left">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.data.map((member: Record<string, unknown>) => (
|
||||
<tr key={String(member.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3">{String(member.member_number ?? '—')}</td>
|
||||
<td className="p-3 font-medium">{String(member.last_name)}, {String(member.first_name)}</td>
|
||||
<td className="p-3">{String(member.email ?? '—')}</td>
|
||||
<td className="p-3">{String(member.postal_code ?? '')} {String(member.city ?? '')}</td>
|
||||
<td className="p-3">{String(member.status)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<CmsPageShell account={account} title="Mitglieder" description={`${result.total} Mitglieder`}>
|
||||
<MembersDataTable
|
||||
data={result.data}
|
||||
total={result.total}
|
||||
page={page}
|
||||
pageSize={25}
|
||||
account={account}
|
||||
duesCategories={(duesCategories ?? []).map((c: Record<string, unknown>) => ({
|
||||
id: String(c.id), name: String(c.name),
|
||||
}))}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
import { StatsBarChart, StatsPieChart } from '~/components/stats-charts';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -27,65 +28,48 @@ export default async function MemberStatisticsPage({ params }: PageProps) {
|
||||
const api = createMemberManagementApi(client);
|
||||
const stats = await api.getMemberStatistics(acct.id);
|
||||
|
||||
const statusChartData = [
|
||||
{ name: 'Aktiv', value: stats.active ?? 0 },
|
||||
{ name: 'Inaktiv', value: stats.inactive ?? 0 },
|
||||
{ name: 'Ausstehend', value: stats.pending ?? 0 },
|
||||
{ name: 'Ausgetreten', value: stats.resigned ?? 0 },
|
||||
];
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Mitglieder-Statistiken">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Mitglieder-Statistiken</h1>
|
||||
<p className="text-muted-foreground">Übersicht über Ihre Mitglieder</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title="Gesamt"
|
||||
value={stats.total ?? 0}
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Aktiv"
|
||||
value={stats.active ?? 0}
|
||||
icon={<UserCheck className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Inaktiv"
|
||||
value={stats.inactive ?? 0}
|
||||
icon={<UserMinus className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Ausstehend"
|
||||
value={stats.pending ?? 0}
|
||||
icon={<Clock className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard title="Gesamt" value={stats.total ?? 0} icon={<Users className="h-5 w-5" />} />
|
||||
<StatsCard title="Aktiv" value={stats.active ?? 0} icon={<UserCheck className="h-5 w-5" />} />
|
||||
<StatsCard title="Inaktiv" value={stats.inactive ?? 0} icon={<UserMinus className="h-5 w-5" />} />
|
||||
<StatsCard title="Ausstehend" value={stats.pending ?? 0} icon={<Clock className="h-5 w-5" />} />
|
||||
</div>
|
||||
|
||||
{/* Chart Placeholders */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
Mitgliederentwicklung
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-64 items-center justify-center rounded-md border border-dashed text-muted-foreground">
|
||||
Diagramm wird hier angezeigt
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
Mitglieder nach Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatsBarChart data={statusChartData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
Eintritte / Austritte pro Monat
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-64 items-center justify-center rounded-md border border-dashed text-muted-foreground">
|
||||
Diagramm wird hier angezeigt
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
Verteilung
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatsPieChart data={statusChartData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
|
||||
@@ -1,125 +1,18 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { Textarea } from '@kit/ui/textarea';
|
||||
|
||||
import { CreateNewsletterForm } from '@kit/newsletter/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
|
||||
export default async function NewNewsletterPage({ params }: PageProps) {
|
||||
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 <div>Konto nicht gefunden</div>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neuer Newsletter">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Back link */}
|
||||
<div>
|
||||
<Link
|
||||
href={`/home/${account}/newsletter`}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Zurück zu Newsletter
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Neuer Newsletter</CardTitle>
|
||||
<CardDescription>
|
||||
Erstellen Sie eine neue Newsletter-Kampagne.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-5">
|
||||
{/* Betreff */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="subject">Betreff</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
name="subject"
|
||||
placeholder="z.B. Monatliche Vereinsnachrichten März 2026"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Body HTML */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bodyHtml">Inhalt (HTML)</Label>
|
||||
<Textarea
|
||||
id="bodyHtml"
|
||||
name="bodyHtml"
|
||||
placeholder="<h1>Hallo {{first_name}},</h1> <p>Neuigkeiten aus dem Verein...</p>"
|
||||
rows={10}
|
||||
className="font-mono text-sm"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Verwenden Sie {'{{first_name}}'}, {'{{name}}'} und{' '}
|
||||
{'{{email}}'} als Platzhalter für die Personalisierung.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Empfänger Info */}
|
||||
<div className="rounded-md bg-muted/50 p-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<strong>Empfänger-Auswahl:</strong> Nach dem Erstellen können
|
||||
Sie die Empfänger aus Ihrer Mitgliederliste auswählen. Es
|
||||
werden nur Mitglieder mit hinterlegter E-Mail-Adresse
|
||||
berücksichtigt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Vorlage Auswahl */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="templateId">
|
||||
Vorlage (optional)
|
||||
</Label>
|
||||
<select
|
||||
id="templateId"
|
||||
name="templateId"
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="">Keine Vorlage</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-between">
|
||||
<Link href={`/home/${account}/newsletter`}>
|
||||
<Button variant="outline">Abbrechen</Button>
|
||||
</Link>
|
||||
<Button type="submit">Newsletter erstellen</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
<CmsPageShell account={account} title="Neuer Newsletter" description="Newsletter-Kampagne erstellen">
|
||||
<CreateNewsletterForm accountId={acct.id} account={account} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createSiteBuilderApi } from '@kit/site-builder/api';
|
||||
import { SiteEditor } from '@kit/site-builder/components';
|
||||
|
||||
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();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createSiteBuilderApi(client);
|
||||
const page = await api.getPage(pageId);
|
||||
if (!page) return <div>Seite nicht gefunden</div>;
|
||||
|
||||
return <SiteEditor pageId={pageId} accountId={acct.id} initialData={(page.puck_data ?? {}) as Record<string, unknown>} />;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { CreatePageForm } from '@kit/site-builder/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
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();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neue Seite" description="Seite für Ihre Vereinswebsite erstellen">
|
||||
<CreatePageForm accountId={acct.id} account={account} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
95
apps/web/app/[locale]/home/[account]/site-builder/page.tsx
Normal file
95
apps/web/app/[locale]/home/[account]/site-builder/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createSiteBuilderApi } from '@kit/site-builder/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Plus, Globe, FileText, Settings, ExternalLink } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
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();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createSiteBuilderApi(client);
|
||||
const pages = await api.listPages(acct.id);
|
||||
const settings = await api.getSiteSettings(acct.id);
|
||||
const posts = await api.listPosts(acct.id);
|
||||
|
||||
const publishedCount = pages.filter((p: any) => p.is_published).length;
|
||||
|
||||
return (
|
||||
<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>
|
||||
</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>
|
||||
</Link>
|
||||
{settings?.is_public && (
|
||||
<a href={`/club/${account}`} target="_blank" rel="noopener">
|
||||
<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>
|
||||
</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-2xl font-bold">{settings?.is_public ? '🟢 Online' : '🔴 Offline'}</p></CardContent></Card>
|
||||
</div>
|
||||
|
||||
{pages.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Globe className="h-8 w-8" />}
|
||||
title="Noch keine Seiten"
|
||||
description="Erstellen Sie Ihre erste Seite mit dem visuellen Editor."
|
||||
actionLabel="Erste Seite erstellen"
|
||||
actionHref={`/home/${account}/site-builder/new`}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">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>
|
||||
<tbody>
|
||||
{pages.map((page: Record<string, unknown>) => (
|
||||
<tr key={String(page.id)} className="border-b hover:bg-muted/30">
|
||||
<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="p-3">
|
||||
<Link href={`/home/${account}/site-builder/${String(page.id)}/edit`}>
|
||||
<Button size="sm" variant="outline">Bearbeiten</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { CreatePostForm } from '@kit/site-builder/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
|
||||
export default async function NewPostPage({ params }: Props) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neuer Beitrag" description="Beitrag erstellen">
|
||||
<CreatePostForm accountId={acct.id} account={account} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createSiteBuilderApi } from '@kit/site-builder/api';
|
||||
import { Card, CardContent } from '@kit/ui/card';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Plus } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
|
||||
export default async function PostsManagerPage({ params }: Props) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createSiteBuilderApi(client);
|
||||
const posts = await api.listPosts(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Beiträge" description="Neuigkeiten und Artikel verwalten">
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<Button><Plus className="mr-2 h-4 w-4" />Neuer Beitrag</Button>
|
||||
</div>
|
||||
{posts.length === 0 ? (
|
||||
<EmptyState title="Keine Beiträge" description="Erstellen Sie Ihren ersten Beitrag." actionLabel="Beitrag erstellen" />
|
||||
) : (
|
||||
<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">Titel</th>
|
||||
<th className="p-3 text-left">Status</th>
|
||||
<th className="p-3 text-left">Erstellt</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{posts.map((post: Record<string, unknown>) => (
|
||||
<tr key={String(post.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">{String(post.title)}</td>
|
||||
<td className="p-3"><Badge variant={post.status === 'published' ? 'default' : 'secondary'}>{String(post.status)}</Badge></td>
|
||||
<td className="p-3 text-xs text-muted-foreground">{post.created_at ? new Date(String(post.created_at)).toLocaleDateString('de-DE') : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createSiteBuilderApi } from '@kit/site-builder/api';
|
||||
import { SiteSettingsForm } from '@kit/site-builder/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
|
||||
export default async function SiteSettingsPage({ params }: Props) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createSiteBuilderApi(client);
|
||||
const settings = await api.getSiteSettings(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Website-Einstellungen" description="Design und Kontaktdaten">
|
||||
<SiteSettingsForm accountId={acct.id} account={account} settings={settings} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
75
apps/web/app/api/club/accept-invite/route.ts
Normal file
75
apps/web/app/api/club/accept-invite/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const token = formData.get('token') as string;
|
||||
const slug = formData.get('slug') as string;
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
if (!token || !password || password.length < 8) {
|
||||
return NextResponse.json({ error: 'Ungültige Eingabe' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Use service role to create user + link member
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SECRET_KEY!,
|
||||
);
|
||||
|
||||
// 1. Get invitation
|
||||
const { data: invitation, error: invError } = await supabase
|
||||
.from('member_portal_invitations')
|
||||
.select('id, email, member_id, account_id, status, expires_at')
|
||||
.eq('invite_token', token)
|
||||
.single();
|
||||
|
||||
if (invError || !invitation || invitation.status !== 'pending') {
|
||||
return NextResponse.redirect(new URL(`/club/${slug}/portal/invite?token=${token}&error=invalid`, request.url));
|
||||
}
|
||||
|
||||
if (new Date(invitation.expires_at) < new Date()) {
|
||||
return NextResponse.redirect(new URL(`/club/${slug}/portal/invite?token=${token}&error=expired`, request.url));
|
||||
}
|
||||
|
||||
// 2. Create auth user
|
||||
const { data: authData, error: authError } = await supabase.auth.admin.createUser({
|
||||
email: invitation.email,
|
||||
password,
|
||||
email_confirm: true,
|
||||
user_metadata: { invited_via: 'member_portal' },
|
||||
});
|
||||
|
||||
if (authError) {
|
||||
// User might already exist — try to find them
|
||||
const { data: existingUsers } = await supabase.auth.admin.listUsers();
|
||||
const existing = existingUsers?.users?.find(u => u.email === invitation.email);
|
||||
|
||||
if (existing) {
|
||||
// Link existing user to member
|
||||
await supabase.from('members').update({ user_id: existing.id }).eq('id', invitation.member_id);
|
||||
await supabase.from('member_portal_invitations').update({ status: 'accepted', accepted_at: new Date().toISOString() }).eq('id', invitation.id);
|
||||
return NextResponse.redirect(new URL(`/club/${slug}/portal`, request.url));
|
||||
}
|
||||
|
||||
console.error('[accept-invite] Auth error:', authError.message);
|
||||
return NextResponse.redirect(new URL(`/club/${slug}/portal/invite?token=${token}&error=auth`, request.url));
|
||||
}
|
||||
|
||||
// 3. Link member to user
|
||||
await supabase.from('members').update({ user_id: authData.user.id }).eq('id', invitation.member_id);
|
||||
|
||||
// 4. Mark invitation as accepted
|
||||
await supabase.from('member_portal_invitations').update({
|
||||
status: 'accepted',
|
||||
accepted_at: new Date().toISOString(),
|
||||
}).eq('id', invitation.id);
|
||||
|
||||
// 5. Redirect to portal login
|
||||
return NextResponse.redirect(new URL(`/club/${slug}/portal`, request.url));
|
||||
} catch (err) {
|
||||
console.error('[accept-invite] Error:', err);
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
40
apps/web/app/api/club/contact/route.ts
Normal file
40
apps/web/app/api/club/contact/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { recipientEmail, name, email, subject, message } = body;
|
||||
|
||||
if (!email || !message) {
|
||||
return NextResponse.json({ error: 'E-Mail und Nachricht sind erforderlich' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return NextResponse.json({ error: 'Ungültige E-Mail-Adresse' }, { status: 400 });
|
||||
}
|
||||
|
||||
// In production: use @kit/mailers to send the email
|
||||
// For now: log and return success
|
||||
console.log('[contact] Form submission:', {
|
||||
to: recipientEmail || 'admin',
|
||||
from: email,
|
||||
name,
|
||||
subject: subject || 'Kontaktanfrage',
|
||||
message,
|
||||
});
|
||||
|
||||
// TODO: Wire to @kit/mailers
|
||||
// const mailer = await getMailer();
|
||||
// await mailer.sendMail({
|
||||
// to: recipientEmail,
|
||||
// from: email,
|
||||
// subject: subject || 'Kontaktanfrage von der Website',
|
||||
// text: `Name: ${name}\nE-Mail: ${email}\n\n${message}`,
|
||||
// });
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Nachricht gesendet' });
|
||||
} catch (err) {
|
||||
console.error('[contact] Error:', err);
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
42
apps/web/app/api/club/newsletter/route.ts
Normal file
42
apps/web/app/api/club/newsletter/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { accountId, email, name } = body;
|
||||
|
||||
if (!accountId || !email) {
|
||||
return NextResponse.json({ error: 'accountId und email sind erforderlich' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return NextResponse.json({ error: 'Ungültige E-Mail-Adresse' }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
||||
);
|
||||
|
||||
const token = crypto.randomUUID();
|
||||
const { error } = await supabase.from('newsletter_subscriptions').upsert({
|
||||
account_id: accountId,
|
||||
email,
|
||||
name: name || null,
|
||||
confirmation_token: token,
|
||||
is_active: true,
|
||||
}, { onConflict: 'account_id,email' });
|
||||
|
||||
if (error) {
|
||||
console.error('[newsletter] Subscription error:', error.message);
|
||||
return NextResponse.json({ error: 'Anmeldung fehlgeschlagen' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Erfolgreich angemeldet' });
|
||||
} catch (err) {
|
||||
console.error('[newsletter] Error:', err);
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
71
apps/web/components/stats-charts.tsx
Normal file
71
apps/web/components/stats-charts.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
PieChart, Pie, Cell, Legend,
|
||||
} from 'recharts';
|
||||
|
||||
const COLORS = ['#0d9488', '#14b8a6', '#2dd4bf', '#5eead4', '#99f6e4', '#ccfbf1'];
|
||||
|
||||
interface BarChartData {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface PieChartData {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export function StatsBarChart({ data, title }: { data: BarChartData[]; title?: string }) {
|
||||
if (data.length === 0 || data.every(d => d.value === 0)) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
|
||||
Noch keine Daten vorhanden
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-64">
|
||||
{title && <p className="mb-2 text-sm font-medium text-muted-foreground">{title}</p>}
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="name" className="text-xs" />
|
||||
<YAxis className="text-xs" />
|
||||
<Tooltip />
|
||||
<Bar dataKey="value" fill="var(--primary, #0d9488)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsPieChart({ data, title }: { data: PieChartData[]; title?: string }) {
|
||||
const filtered = data.filter(d => d.value > 0);
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
|
||||
Noch keine Daten vorhanden
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-64">
|
||||
{title && <p className="mb-2 text-sm font-medium text-muted-foreground">{title}</p>}
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={filtered} cx="50%" cy="50%" innerRadius={50} outerRadius={80} dataKey="value" label={({ name, percent }) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}>
|
||||
{filtered.map((_, i) => (
|
||||
<Cell key={`cell-${i}`} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Legend />
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,7 @@ const FeatureFlagsSchema = z.object({
|
||||
enableDocumentGeneration: z.boolean().default(true),
|
||||
enableNewsletter: z.boolean().default(true),
|
||||
enableGdprCompliance: z.boolean().default(true),
|
||||
enableSiteBuilder: z.boolean().default(true),
|
||||
});
|
||||
|
||||
const featuresFlagConfig = FeatureFlagsSchema.parse({
|
||||
@@ -132,6 +133,10 @@ const featuresFlagConfig = FeatureFlagsSchema.parse({
|
||||
process.env.NEXT_PUBLIC_ENABLE_GDPR_COMPLIANCE,
|
||||
true,
|
||||
),
|
||||
enableSiteBuilder: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_SITE_BUILDER,
|
||||
true,
|
||||
),
|
||||
} satisfies z.output<typeof FeatureFlagsSchema>);
|
||||
|
||||
export default featuresFlagConfig;
|
||||
|
||||
@@ -30,6 +30,7 @@ const PathsSchema = z.object({
|
||||
accountFinance: z.string().min(1),
|
||||
accountDocuments: z.string().min(1),
|
||||
accountNewsletter: z.string().min(1),
|
||||
accountSiteBuilder: z.string().min(1),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -63,6 +64,7 @@ const pathsConfig = PathsSchema.parse({
|
||||
accountFinance: `/home/[account]/finance`,
|
||||
accountDocuments: `/home/[account]/documents`,
|
||||
accountNewsletter: `/home/[account]/newsletter`,
|
||||
accountSiteBuilder: `/home/[account]/site-builder`,
|
||||
},
|
||||
} satisfies z.output<typeof PathsSchema>);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
CreditCard, LayoutDashboard, Settings, Users, Database,
|
||||
UserCheck, GraduationCap, Hotel, Calendar, Wallet,
|
||||
FileText, Mail,
|
||||
FileText, Mail, Globe,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
|
||||
@@ -75,6 +75,11 @@ const getRoutes = (account: string) => [
|
||||
Icon: <Mail className={iconClasses} />,
|
||||
}
|
||||
: undefined,
|
||||
{
|
||||
label: 'common.routes.siteBuilder',
|
||||
path: createPath(`/home/[account]/site-builder`, account),
|
||||
Icon: <Globe className={iconClasses} />,
|
||||
},
|
||||
].filter(Boolean),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -74,7 +74,8 @@
|
||||
"finance": "Finanzen",
|
||||
"documents": "Dokumente",
|
||||
"newsletter": "Newsletter",
|
||||
"events": "Veranstaltungen"
|
||||
"events": "Veranstaltungen",
|
||||
"siteBuilder": "Website"
|
||||
},
|
||||
"roles": {
|
||||
"owner": {
|
||||
|
||||
@@ -41,6 +41,78 @@
|
||||
"contactError": "Fehler beim Senden Ihrer Nachricht",
|
||||
"contactSuccessDescription": "Wir haben Ihre Nachricht erhalten und melden uns schnellstmöglich",
|
||||
"contactErrorDescription": "Beim Senden ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut",
|
||||
"footerDescription": "Hier können Sie eine Beschreibung Ihres Unternehmens oder Produkts einfügen",
|
||||
"copyright": "© Copyright {year} {product}. Alle Rechte vorbehalten."
|
||||
"footerDescription": "Die All-in-One-Verwaltungsplattform für Vereine, Clubs und Organisationen. Entwickelt von Com.BISS GmbH.",
|
||||
"copyright": "© Copyright {year} {product}. Alle Rechte vorbehalten.",
|
||||
|
||||
"heroPill": "Die nächste Generation der Vereinsverwaltung",
|
||||
"heroTitle": "Verwalten Sie Ihre Organisation. Einfach und effizient.",
|
||||
"heroSubtitle": "MyEasyCMS ist die All-in-One-Plattform für Vereine, Clubs und Organisationen. Verwalten Sie Mitglieder, Kurse, Veranstaltungen, Finanzen und mehr — alles an einem Ort.",
|
||||
|
||||
"trustedBy": "Vertraut von Vereinen und Clubs in ganz Deutschland",
|
||||
"trustAssociations": "Vereine",
|
||||
"trustSchools": "Bildungseinrichtungen",
|
||||
"trustClubs": "Sport- & Angelvereine",
|
||||
"trustOrganizations": "Gemeinnützige Organisationen",
|
||||
|
||||
"featuresHeading": "Alles, was Ihre Organisation braucht",
|
||||
"featuresSubheading": "Von der Mitgliederverwaltung bis zur Finanzbuchhaltung — alle Werkzeuge in einer modernen, benutzerfreundlichen Plattform.",
|
||||
"featuresLabel": "Kernmodule",
|
||||
|
||||
"featureMembersTitle": "Mitgliederverwaltung",
|
||||
"featureMembersDesc": "Verwalten Sie alle Mitglieder mit Abteilungen, Beitragsverfolgung, Mitgliedsausweisen, Anträgen und detaillierten Statistiken.",
|
||||
"featureCoursesTitle": "Kursverwaltung",
|
||||
"featureCoursesDesc": "Organisieren Sie Kurse mit Terminplanung, Dozentenzuweisung, Anwesenheitsverfolgung, Kategorien und Standorten.",
|
||||
"featureBookingsTitle": "Raumbuchungen",
|
||||
"featureBookingsDesc": "Buchen Sie Räume und Ressourcen mit einem visuellen Kalender, verwalten Sie Gäste und prüfen Sie die Verfügbarkeit.",
|
||||
"featureEventsTitle": "Veranstaltungsverwaltung",
|
||||
"featureEventsDesc": "Planen und verwalten Sie Veranstaltungen mit Anmeldungen, Ferienpässen und Teilnehmerverfolgung.",
|
||||
"featureFinanceTitle": "Finanzen & Abrechnung",
|
||||
"featureFinanceDesc": "Erstellen Sie Rechnungen, verwalten Sie Zahlungen und SEPA-Lastschrifteinzüge — behalten Sie Ihre Finanzen mühelos im Griff.",
|
||||
"featureNewsletterTitle": "Newsletter",
|
||||
"featureNewsletterDesc": "Erstellen und versenden Sie professionelle Newsletter mit Vorlagen. Halten Sie Ihre Mitglieder informiert.",
|
||||
|
||||
"showcaseHeading": "Ein leistungsstarkes Dashboard auf einen Blick",
|
||||
"showcaseDescription": "Erhalten Sie einen vollständigen Überblick über Ihre Organisation mit unserem intuitiven Dashboard. Greifen Sie auf alles zu — Mitglieder, Kurse, Veranstaltungen und Finanzen — von einer zentralen Stelle aus.",
|
||||
|
||||
"additionalFeaturesHeading": "Und es gibt noch mehr",
|
||||
"additionalFeaturesSubheading": "Zusätzliche Werkzeuge, die jeden Aspekt der täglichen Arbeit Ihrer Organisation vereinfachen.",
|
||||
"additionalFeaturesLabel": "Weitere Funktionen",
|
||||
|
||||
"featureDocumentsTitle": "Dokumentenverwaltung",
|
||||
"featureDocumentsDesc": "Erstellen Sie Dokumente aus Vorlagen, verwalten Sie Dateien und halten Sie alle wichtigen Unterlagen organisiert.",
|
||||
"featureSiteBuilderTitle": "Website-Baukasten",
|
||||
"featureSiteBuilderDesc": "Erstellen und verwalten Sie die Website Ihrer Organisation ohne Programmierkenntnisse. Aktualisieren Sie Inhalte ganz einfach.",
|
||||
"featureModulesTitle": "Individuelle Module",
|
||||
"featureModulesDesc": "Erweitern Sie die Plattform mit maßgeschneiderten Modulen für Ihre spezifischen Anforderungen. Importieren Sie Daten und passen Sie Einstellungen an.",
|
||||
|
||||
"whyChooseHeading": "Warum Organisationen MyEasyCMS wählen",
|
||||
"whyChooseDescription": "Entwickelt mit über 20 Jahren Erfahrung im Dienste von Vereinen, Clubs und gemeinnützigen Organisationen in ganz Deutschland.",
|
||||
"whyResponsiveTitle": "Mobilfreundlich",
|
||||
"whyResponsiveDesc": "Greifen Sie von jedem Gerät auf Ihre Daten zu. Unser responsives Design funktioniert perfekt auf Desktop, Tablet und Smartphone.",
|
||||
"whySecureTitle": "Sicher & Zuverlässig",
|
||||
"whySecureDesc": "Ihre Daten sind mit erstklassiger Sicherheit geschützt. Regelmäßige Backups stellen sicher, dass nichts verloren geht.",
|
||||
"whySupportTitle": "Persönlicher Support",
|
||||
"whySupportDesc": "Erhalten Sie direkten, persönlichen Support von unserem Team. Wir sprechen Ihre Sprache und verstehen Ihre Bedürfnisse.",
|
||||
"whyGdprTitle": "DSGVO-konform",
|
||||
"whyGdprDesc": "Vollständig konform mit der europäischen Datenschutz-Grundverordnung. Die Daten Ihrer Mitglieder werden sorgfältig behandelt.",
|
||||
|
||||
"howItWorksHeading": "In drei einfachen Schritten loslegen",
|
||||
"howItWorksSubheading": "Die Einrichtung Ihrer Organisation auf MyEasyCMS dauert nur wenige Minuten.",
|
||||
"howStep1Title": "Konto erstellen",
|
||||
"howStep1Desc": "Registrieren Sie sich kostenlos und richten Sie Ihr Organisationsprofil ein. Keine Kreditkarte erforderlich.",
|
||||
"howStep2Title": "Module konfigurieren",
|
||||
"howStep2Desc": "Aktivieren Sie die benötigten Module — Mitglieder, Kurse, Veranstaltungen, Finanzen — und passen Sie diese an Ihren Workflow an.",
|
||||
"howStep3Title": "Team einladen",
|
||||
"howStep3Desc": "Fügen Sie Teammitglieder mit verschiedenen Rollen und Berechtigungen hinzu. Verwalten Sie Ihre Organisation gemeinsam.",
|
||||
|
||||
"pricingPillLabel": "Kostenlos starten",
|
||||
"pricingPillText": "Keine Kreditkarte erforderlich.",
|
||||
"pricingHeading": "Faire Preise für alle Arten von Organisationen",
|
||||
"pricingSubheading": "Starten Sie mit unserem kostenlosen Tarif und upgraden Sie, wenn Sie bereit sind.",
|
||||
|
||||
"ctaHeading": "Bereit, die Verwaltung Ihrer Organisation zu vereinfachen?",
|
||||
"ctaDescription": "Schließen Sie sich hunderten von Vereinen, Clubs und Organisationen an, die MyEasyCMS bereits nutzen.",
|
||||
"ctaButtonPrimary": "Jetzt kostenlos starten",
|
||||
"ctaButtonSecondary": "Kontakt aufnehmen",
|
||||
"ctaNote": "Keine Kreditkarte erforderlich. Kostenloser Tarif verfügbar."
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"courses": "Courses",
|
||||
"bookings": "Bookings",
|
||||
"events": "Events",
|
||||
"siteBuilder": "Website",
|
||||
"finance": "Finance",
|
||||
"documents": "Documents",
|
||||
"newsletter": "Newsletter"
|
||||
|
||||
@@ -41,6 +41,78 @@
|
||||
"contactError": "An error occurred while sending your message",
|
||||
"contactSuccessDescription": "We have received your message and will get back to you as soon as possible",
|
||||
"contactErrorDescription": "An error occurred while sending your message. Please try again later",
|
||||
"footerDescription": "Here you can add a description about your company or product",
|
||||
"copyright": "© Copyright {year} {product}. All Rights Reserved."
|
||||
"footerDescription": "The all-in-one management platform for associations, clubs, and organizations. Built by Com.BISS GmbH.",
|
||||
"copyright": "© Copyright {year} {product}. All Rights Reserved.",
|
||||
|
||||
"heroPill": "The next generation of association management",
|
||||
"heroTitle": "Manage your organization. Simply and efficiently.",
|
||||
"heroSubtitle": "MyEasyCMS is the all-in-one platform for associations, clubs, and organizations. Manage members, courses, events, finances, and more — all from one place.",
|
||||
|
||||
"trustedBy": "Trusted by associations and clubs across Germany",
|
||||
"trustAssociations": "Associations",
|
||||
"trustSchools": "Educational Institutions",
|
||||
"trustClubs": "Sports & Fishing Clubs",
|
||||
"trustOrganizations": "Non-Profit Organizations",
|
||||
|
||||
"featuresHeading": "Everything your organization needs",
|
||||
"featuresSubheading": "From member management to finance — all the tools you need in one modern, easy-to-use platform.",
|
||||
"featuresLabel": "Core Modules",
|
||||
|
||||
"featureMembersTitle": "Member Management",
|
||||
"featureMembersDesc": "Manage all your members with departments, dues tracking, membership cards, applications, and detailed statistics.",
|
||||
"featureCoursesTitle": "Course Management",
|
||||
"featureCoursesDesc": "Organize courses with scheduling, instructor assignment, attendance tracking, categories, and locations.",
|
||||
"featureBookingsTitle": "Room Bookings",
|
||||
"featureBookingsDesc": "Book rooms and resources with a visual calendar, manage guests, and track availability at a glance.",
|
||||
"featureEventsTitle": "Event Management",
|
||||
"featureEventsDesc": "Plan and manage events with registrations, holiday passes, and participant tracking.",
|
||||
"featureFinanceTitle": "Finance & Billing",
|
||||
"featureFinanceDesc": "Handle invoices, payments, and SEPA direct debit collections — keep your finances organized effortlessly.",
|
||||
"featureNewsletterTitle": "Newsletter",
|
||||
"featureNewsletterDesc": "Create and send professional newsletters with templates. Keep your members informed and engaged.",
|
||||
|
||||
"showcaseHeading": "A powerful dashboard at your fingertips",
|
||||
"showcaseDescription": "Get a complete overview of your organization with our intuitive dashboard. Access everything you need — members, courses, events, and finances — from one central hub.",
|
||||
|
||||
"additionalFeaturesHeading": "And there's more",
|
||||
"additionalFeaturesSubheading": "Additional tools to streamline every aspect of your organization's daily work.",
|
||||
"additionalFeaturesLabel": "More Features",
|
||||
|
||||
"featureDocumentsTitle": "Document Management",
|
||||
"featureDocumentsDesc": "Generate documents from templates, manage files, and keep all your important documents organized.",
|
||||
"featureSiteBuilderTitle": "Website Builder",
|
||||
"featureSiteBuilderDesc": "Create and manage your organization's website without any programming knowledge. Update content with ease.",
|
||||
"featureModulesTitle": "Custom Modules",
|
||||
"featureModulesDesc": "Extend the platform with custom modules tailored to your specific needs. Import data and configure settings.",
|
||||
|
||||
"whyChooseHeading": "Why organizations choose MyEasyCMS",
|
||||
"whyChooseDescription": "Built with over 20 years of experience serving associations, clubs, and non-profit organizations across Germany.",
|
||||
"whyResponsiveTitle": "Mobile-Friendly",
|
||||
"whyResponsiveDesc": "Access your data from any device. Our responsive design works perfectly on desktop, tablet, and smartphone.",
|
||||
"whySecureTitle": "Secure & Reliable",
|
||||
"whySecureDesc": "Your data is protected with enterprise-grade security. Regular backups ensure nothing is ever lost.",
|
||||
"whySupportTitle": "Personal Support",
|
||||
"whySupportDesc": "Get direct, personal support from our team. We speak your language and understand your needs.",
|
||||
"whyGdprTitle": "GDPR Compliant",
|
||||
"whyGdprDesc": "Fully compliant with European data protection regulations. Your members' data is handled with care.",
|
||||
|
||||
"howItWorksHeading": "Get started in three easy steps",
|
||||
"howItWorksSubheading": "Setting up your organization on MyEasyCMS takes just minutes.",
|
||||
"howStep1Title": "Create your account",
|
||||
"howStep1Desc": "Sign up for free and set up your organization profile. No credit card required to get started.",
|
||||
"howStep2Title": "Configure your modules",
|
||||
"howStep2Desc": "Activate the modules you need — members, courses, events, finance — and customize them to fit your workflow.",
|
||||
"howStep3Title": "Invite your team",
|
||||
"howStep3Desc": "Add team members with different roles and permissions. Start managing your organization collaboratively.",
|
||||
|
||||
"pricingPillLabel": "Start for free",
|
||||
"pricingPillText": "No credit card required.",
|
||||
"pricingHeading": "Fair pricing for all types of organizations",
|
||||
"pricingSubheading": "Get started on our free plan and upgrade when you are ready.",
|
||||
|
||||
"ctaHeading": "Ready to simplify your organization's management?",
|
||||
"ctaDescription": "Join hundreds of associations, clubs, and organizations who already use MyEasyCMS to streamline their work.",
|
||||
"ctaButtonPrimary": "Get Started for Free",
|
||||
"ctaButtonSecondary": "Contact Us",
|
||||
"ctaNote": "No credit card required. Free plan available."
|
||||
}
|
||||
|
||||
@@ -496,6 +496,73 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
cms_posts: {
|
||||
Row: {
|
||||
account_id: string
|
||||
author_id: string | null
|
||||
content: string | null
|
||||
cover_image: string | null
|
||||
created_at: string
|
||||
excerpt: string | null
|
||||
id: string
|
||||
published_at: string | null
|
||||
slug: string
|
||||
status: string
|
||||
title: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
account_id: string
|
||||
author_id?: string | null
|
||||
content?: string | null
|
||||
cover_image?: string | null
|
||||
created_at?: string
|
||||
excerpt?: string | null
|
||||
id?: string
|
||||
published_at?: string | null
|
||||
slug: string
|
||||
status?: string
|
||||
title: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
account_id?: string
|
||||
author_id?: string | null
|
||||
content?: string | null
|
||||
cover_image?: string | null
|
||||
created_at?: string
|
||||
excerpt?: string | null
|
||||
id?: string
|
||||
published_at?: string | null
|
||||
slug?: string
|
||||
status?: string
|
||||
title?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "cms_posts_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "accounts"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "cms_posts_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_account_workspace"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "cms_posts_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_accounts"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
config: {
|
||||
Row: {
|
||||
billing_provider: Database["public"]["Enums"]["billing_provider"]
|
||||
@@ -963,6 +1030,8 @@ export type Database = {
|
||||
id: string
|
||||
interval: string
|
||||
is_default: boolean
|
||||
is_exit: boolean
|
||||
is_youth: boolean
|
||||
name: string
|
||||
sort_order: number
|
||||
}
|
||||
@@ -974,6 +1043,8 @@ export type Database = {
|
||||
id?: string
|
||||
interval?: string
|
||||
is_default?: boolean
|
||||
is_exit?: boolean
|
||||
is_youth?: boolean
|
||||
name: string
|
||||
sort_order?: number
|
||||
}
|
||||
@@ -985,6 +1056,8 @@ export type Database = {
|
||||
id?: string
|
||||
interval?: string
|
||||
is_default?: boolean
|
||||
is_exit?: boolean
|
||||
is_youth?: boolean
|
||||
name?: string
|
||||
sort_order?: number
|
||||
}
|
||||
@@ -1671,126 +1744,499 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
member_department_assignments: {
|
||||
Row: {
|
||||
department_id: string
|
||||
member_id: string
|
||||
}
|
||||
Insert: {
|
||||
department_id: string
|
||||
member_id: string
|
||||
}
|
||||
Update: {
|
||||
department_id?: string
|
||||
member_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "member_department_assignments_department_id_fkey"
|
||||
columns: ["department_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "member_departments"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "member_department_assignments_member_id_fkey"
|
||||
columns: ["member_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "members"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
member_departments: {
|
||||
Row: {
|
||||
account_id: string
|
||||
created_at: string
|
||||
description: string | null
|
||||
id: string
|
||||
name: string
|
||||
sort_order: number
|
||||
}
|
||||
Insert: {
|
||||
account_id: string
|
||||
created_at?: string
|
||||
description?: string | null
|
||||
id?: string
|
||||
name: string
|
||||
sort_order?: number
|
||||
}
|
||||
Update: {
|
||||
account_id?: string
|
||||
created_at?: string
|
||||
description?: string | null
|
||||
id?: string
|
||||
name?: string
|
||||
sort_order?: number
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "member_departments_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "accounts"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "member_departments_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_account_workspace"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "member_departments_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_accounts"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
member_honors: {
|
||||
Row: {
|
||||
account_id: string
|
||||
created_at: string
|
||||
description: string | null
|
||||
honor_date: string | null
|
||||
honor_name: string
|
||||
id: string
|
||||
member_id: string
|
||||
}
|
||||
Insert: {
|
||||
account_id: string
|
||||
created_at?: string
|
||||
description?: string | null
|
||||
honor_date?: string | null
|
||||
honor_name: string
|
||||
id?: string
|
||||
member_id: string
|
||||
}
|
||||
Update: {
|
||||
account_id?: string
|
||||
created_at?: string
|
||||
description?: string | null
|
||||
honor_date?: string | null
|
||||
honor_name?: string
|
||||
id?: string
|
||||
member_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "member_honors_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "accounts"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "member_honors_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_account_workspace"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "member_honors_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_accounts"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "member_honors_member_id_fkey"
|
||||
columns: ["member_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "members"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
member_portal_invitations: {
|
||||
Row: {
|
||||
accepted_at: string | null
|
||||
account_id: string
|
||||
created_at: string
|
||||
email: string
|
||||
expires_at: string
|
||||
id: string
|
||||
invite_token: string
|
||||
invited_by: string | null
|
||||
member_id: string
|
||||
status: string
|
||||
}
|
||||
Insert: {
|
||||
accepted_at?: string | null
|
||||
account_id: string
|
||||
created_at?: string
|
||||
email: string
|
||||
expires_at?: string
|
||||
id?: string
|
||||
invite_token?: string
|
||||
invited_by?: string | null
|
||||
member_id: string
|
||||
status?: string
|
||||
}
|
||||
Update: {
|
||||
accepted_at?: string | null
|
||||
account_id?: string
|
||||
created_at?: string
|
||||
email?: string
|
||||
expires_at?: string
|
||||
id?: string
|
||||
invite_token?: string
|
||||
invited_by?: string | null
|
||||
member_id?: string
|
||||
status?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "member_portal_invitations_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "accounts"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "member_portal_invitations_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_account_workspace"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "member_portal_invitations_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_accounts"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "member_portal_invitations_member_id_fkey"
|
||||
columns: ["member_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "members"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
member_roles: {
|
||||
Row: {
|
||||
account_id: string
|
||||
created_at: string
|
||||
from_date: string | null
|
||||
id: string
|
||||
is_active: boolean
|
||||
member_id: string
|
||||
role_name: string
|
||||
until_date: string | null
|
||||
}
|
||||
Insert: {
|
||||
account_id: string
|
||||
created_at?: string
|
||||
from_date?: string | null
|
||||
id?: string
|
||||
is_active?: boolean
|
||||
member_id: string
|
||||
role_name: string
|
||||
until_date?: string | null
|
||||
}
|
||||
Update: {
|
||||
account_id?: string
|
||||
created_at?: string
|
||||
from_date?: string | null
|
||||
id?: string
|
||||
is_active?: boolean
|
||||
member_id?: string
|
||||
role_name?: string
|
||||
until_date?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "member_roles_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "accounts"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "member_roles_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_account_workspace"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "member_roles_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_accounts"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "member_roles_member_id_fkey"
|
||||
columns: ["member_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "members"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
members: {
|
||||
Row: {
|
||||
account_holder: string | null
|
||||
account_id: string
|
||||
additional_fees: number | null
|
||||
address_invalid: boolean
|
||||
bic: string | null
|
||||
birth_country: string | null
|
||||
birthplace: string | null
|
||||
city: string | null
|
||||
country: string | null
|
||||
created_at: string
|
||||
created_by: string | null
|
||||
custom_data: Json
|
||||
data_reconciliation_needed: boolean
|
||||
date_of_birth: string | null
|
||||
dues_category_id: string | null
|
||||
dues_paid: boolean
|
||||
dues_year: number | null
|
||||
email: string | null
|
||||
email_confirmed: boolean
|
||||
entry_date: string
|
||||
exemption_amount: number | null
|
||||
exemption_reason: string | null
|
||||
exemption_type: string | null
|
||||
exit_date: string | null
|
||||
exit_reason: string | null
|
||||
fax: string | null
|
||||
first_name: string
|
||||
gdpr_birthday_info: boolean
|
||||
gdpr_consent: boolean
|
||||
gdpr_consent_date: string | null
|
||||
gdpr_data_source: string | null
|
||||
gdpr_internet: boolean
|
||||
gdpr_newsletter: boolean
|
||||
gdpr_print: boolean
|
||||
gender: string | null
|
||||
guardian_email: string | null
|
||||
guardian_name: string | null
|
||||
guardian_phone: string | null
|
||||
house_number: string | null
|
||||
iban: string | null
|
||||
id: string
|
||||
is_archived: boolean
|
||||
is_founding_member: boolean
|
||||
is_honorary: boolean
|
||||
is_probationary: boolean
|
||||
is_retiree: boolean
|
||||
is_transferred: boolean
|
||||
is_youth: boolean
|
||||
last_name: string
|
||||
member_number: string | null
|
||||
mobile: string | null
|
||||
notes: string | null
|
||||
online_access_blocked: boolean
|
||||
online_access_key: string | null
|
||||
phone: string | null
|
||||
phone2: string | null
|
||||
postal_code: string | null
|
||||
salutation: string | null
|
||||
sepa_bank_name: string | null
|
||||
sepa_mandate_date: string | null
|
||||
sepa_mandate_id: string | null
|
||||
sepa_mandate_reference: string | null
|
||||
sepa_mandate_sequence: string | null
|
||||
sepa_mandate_status:
|
||||
| Database["public"]["Enums"]["sepa_mandate_status"]
|
||||
| null
|
||||
status: Database["public"]["Enums"]["membership_status"]
|
||||
street: string | null
|
||||
street2: string | null
|
||||
title: string | null
|
||||
updated_at: string
|
||||
updated_by: string | null
|
||||
user_id: string | null
|
||||
}
|
||||
Insert: {
|
||||
account_holder?: string | null
|
||||
account_id: string
|
||||
additional_fees?: number | null
|
||||
address_invalid?: boolean
|
||||
bic?: string | null
|
||||
birth_country?: string | null
|
||||
birthplace?: string | null
|
||||
city?: string | null
|
||||
country?: string | null
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
custom_data?: Json
|
||||
data_reconciliation_needed?: boolean
|
||||
date_of_birth?: string | null
|
||||
dues_category_id?: string | null
|
||||
dues_paid?: boolean
|
||||
dues_year?: number | null
|
||||
email?: string | null
|
||||
email_confirmed?: boolean
|
||||
entry_date?: string
|
||||
exemption_amount?: number | null
|
||||
exemption_reason?: string | null
|
||||
exemption_type?: string | null
|
||||
exit_date?: string | null
|
||||
exit_reason?: string | null
|
||||
fax?: string | null
|
||||
first_name: string
|
||||
gdpr_birthday_info?: boolean
|
||||
gdpr_consent?: boolean
|
||||
gdpr_consent_date?: string | null
|
||||
gdpr_data_source?: string | null
|
||||
gdpr_internet?: boolean
|
||||
gdpr_newsletter?: boolean
|
||||
gdpr_print?: boolean
|
||||
gender?: string | null
|
||||
guardian_email?: string | null
|
||||
guardian_name?: string | null
|
||||
guardian_phone?: string | null
|
||||
house_number?: string | null
|
||||
iban?: string | null
|
||||
id?: string
|
||||
is_archived?: boolean
|
||||
is_founding_member?: boolean
|
||||
is_honorary?: boolean
|
||||
is_probationary?: boolean
|
||||
is_retiree?: boolean
|
||||
is_transferred?: boolean
|
||||
is_youth?: boolean
|
||||
last_name: string
|
||||
member_number?: string | null
|
||||
mobile?: string | null
|
||||
notes?: string | null
|
||||
online_access_blocked?: boolean
|
||||
online_access_key?: string | null
|
||||
phone?: string | null
|
||||
phone2?: string | null
|
||||
postal_code?: string | null
|
||||
salutation?: string | null
|
||||
sepa_bank_name?: string | null
|
||||
sepa_mandate_date?: string | null
|
||||
sepa_mandate_id?: string | null
|
||||
sepa_mandate_reference?: string | null
|
||||
sepa_mandate_sequence?: string | null
|
||||
sepa_mandate_status?:
|
||||
| Database["public"]["Enums"]["sepa_mandate_status"]
|
||||
| null
|
||||
status?: Database["public"]["Enums"]["membership_status"]
|
||||
street?: string | null
|
||||
street2?: string | null
|
||||
title?: string | null
|
||||
updated_at?: string
|
||||
updated_by?: string | null
|
||||
user_id?: string | null
|
||||
}
|
||||
Update: {
|
||||
account_holder?: string | null
|
||||
account_id?: string
|
||||
additional_fees?: number | null
|
||||
address_invalid?: boolean
|
||||
bic?: string | null
|
||||
birth_country?: string | null
|
||||
birthplace?: string | null
|
||||
city?: string | null
|
||||
country?: string | null
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
custom_data?: Json
|
||||
data_reconciliation_needed?: boolean
|
||||
date_of_birth?: string | null
|
||||
dues_category_id?: string | null
|
||||
dues_paid?: boolean
|
||||
dues_year?: number | null
|
||||
email?: string | null
|
||||
email_confirmed?: boolean
|
||||
entry_date?: string
|
||||
exemption_amount?: number | null
|
||||
exemption_reason?: string | null
|
||||
exemption_type?: string | null
|
||||
exit_date?: string | null
|
||||
exit_reason?: string | null
|
||||
fax?: string | null
|
||||
first_name?: string
|
||||
gdpr_birthday_info?: boolean
|
||||
gdpr_consent?: boolean
|
||||
gdpr_consent_date?: string | null
|
||||
gdpr_data_source?: string | null
|
||||
gdpr_internet?: boolean
|
||||
gdpr_newsletter?: boolean
|
||||
gdpr_print?: boolean
|
||||
gender?: string | null
|
||||
guardian_email?: string | null
|
||||
guardian_name?: string | null
|
||||
guardian_phone?: string | null
|
||||
house_number?: string | null
|
||||
iban?: string | null
|
||||
id?: string
|
||||
is_archived?: boolean
|
||||
is_founding_member?: boolean
|
||||
is_honorary?: boolean
|
||||
is_probationary?: boolean
|
||||
is_retiree?: boolean
|
||||
is_transferred?: boolean
|
||||
is_youth?: boolean
|
||||
last_name?: string
|
||||
member_number?: string | null
|
||||
mobile?: string | null
|
||||
notes?: string | null
|
||||
online_access_blocked?: boolean
|
||||
online_access_key?: string | null
|
||||
phone?: string | null
|
||||
phone2?: string | null
|
||||
postal_code?: string | null
|
||||
salutation?: string | null
|
||||
sepa_bank_name?: string | null
|
||||
sepa_mandate_date?: string | null
|
||||
sepa_mandate_id?: string | null
|
||||
sepa_mandate_reference?: string | null
|
||||
sepa_mandate_sequence?: string | null
|
||||
sepa_mandate_status?:
|
||||
| Database["public"]["Enums"]["sepa_mandate_status"]
|
||||
| null
|
||||
status?: Database["public"]["Enums"]["membership_status"]
|
||||
street?: string | null
|
||||
street2?: string | null
|
||||
title?: string | null
|
||||
updated_at?: string
|
||||
updated_by?: string | null
|
||||
user_id?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
@@ -2412,6 +2858,64 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
newsletter_subscriptions: {
|
||||
Row: {
|
||||
account_id: string
|
||||
confirmation_token: string | null
|
||||
confirmed_at: string | null
|
||||
email: string
|
||||
id: string
|
||||
is_active: boolean
|
||||
name: string | null
|
||||
subscribed_at: string
|
||||
unsubscribed_at: string | null
|
||||
}
|
||||
Insert: {
|
||||
account_id: string
|
||||
confirmation_token?: string | null
|
||||
confirmed_at?: string | null
|
||||
email: string
|
||||
id?: string
|
||||
is_active?: boolean
|
||||
name?: string | null
|
||||
subscribed_at?: string
|
||||
unsubscribed_at?: string | null
|
||||
}
|
||||
Update: {
|
||||
account_id?: string
|
||||
confirmation_token?: string | null
|
||||
confirmed_at?: string | null
|
||||
email?: string
|
||||
id?: string
|
||||
is_active?: boolean
|
||||
name?: string | null
|
||||
subscribed_at?: string
|
||||
unsubscribed_at?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "newsletter_subscriptions_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "accounts"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "newsletter_subscriptions_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_account_workspace"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "newsletter_subscriptions_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_accounts"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
newsletter_templates: {
|
||||
Row: {
|
||||
account_id: string
|
||||
@@ -3015,6 +3519,259 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
sepa_mandates: {
|
||||
Row: {
|
||||
account_holder: string
|
||||
account_id: string
|
||||
bic: string | null
|
||||
created_at: string
|
||||
has_error: boolean
|
||||
iban: string
|
||||
id: string
|
||||
is_primary: boolean
|
||||
last_used_at: string | null
|
||||
mandate_date: string
|
||||
mandate_reference: string
|
||||
member_id: string
|
||||
notes: string | null
|
||||
sequence: string
|
||||
status: Database["public"]["Enums"]["sepa_mandate_status"]
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
account_holder: string
|
||||
account_id: string
|
||||
bic?: string | null
|
||||
created_at?: string
|
||||
has_error?: boolean
|
||||
iban: string
|
||||
id?: string
|
||||
is_primary?: boolean
|
||||
last_used_at?: string | null
|
||||
mandate_date: string
|
||||
mandate_reference: string
|
||||
member_id: string
|
||||
notes?: string | null
|
||||
sequence?: string
|
||||
status?: Database["public"]["Enums"]["sepa_mandate_status"]
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
account_holder?: string
|
||||
account_id?: string
|
||||
bic?: string | null
|
||||
created_at?: string
|
||||
has_error?: boolean
|
||||
iban?: string
|
||||
id?: string
|
||||
is_primary?: boolean
|
||||
last_used_at?: string | null
|
||||
mandate_date?: string
|
||||
mandate_reference?: string
|
||||
member_id?: string
|
||||
notes?: string | null
|
||||
sequence?: string
|
||||
status?: Database["public"]["Enums"]["sepa_mandate_status"]
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "sepa_mandates_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "accounts"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "sepa_mandates_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_account_workspace"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "sepa_mandates_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_accounts"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "sepa_mandates_member_id_fkey"
|
||||
columns: ["member_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "members"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
site_pages: {
|
||||
Row: {
|
||||
account_id: string
|
||||
created_at: string
|
||||
created_by: string | null
|
||||
id: string
|
||||
is_homepage: boolean
|
||||
is_members_only: boolean
|
||||
is_published: boolean
|
||||
meta_description: string | null
|
||||
meta_image: string | null
|
||||
published_at: string | null
|
||||
puck_data: Json
|
||||
slug: string
|
||||
sort_order: number
|
||||
title: string
|
||||
updated_at: string
|
||||
updated_by: string | null
|
||||
}
|
||||
Insert: {
|
||||
account_id: string
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
id?: string
|
||||
is_homepage?: boolean
|
||||
is_members_only?: boolean
|
||||
is_published?: boolean
|
||||
meta_description?: string | null
|
||||
meta_image?: string | null
|
||||
published_at?: string | null
|
||||
puck_data?: Json
|
||||
slug: string
|
||||
sort_order?: number
|
||||
title: string
|
||||
updated_at?: string
|
||||
updated_by?: string | null
|
||||
}
|
||||
Update: {
|
||||
account_id?: string
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
id?: string
|
||||
is_homepage?: boolean
|
||||
is_members_only?: boolean
|
||||
is_published?: boolean
|
||||
meta_description?: string | null
|
||||
meta_image?: string | null
|
||||
published_at?: string | null
|
||||
puck_data?: Json
|
||||
slug?: string
|
||||
sort_order?: number
|
||||
title?: string
|
||||
updated_at?: string
|
||||
updated_by?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "site_pages_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "accounts"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "site_pages_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_account_workspace"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "site_pages_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_accounts"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
site_settings: {
|
||||
Row: {
|
||||
account_id: string
|
||||
contact_address: string | null
|
||||
contact_email: string | null
|
||||
contact_phone: string | null
|
||||
created_at: string
|
||||
custom_css: string | null
|
||||
custom_domain: string | null
|
||||
datenschutz: string | null
|
||||
font_family: string | null
|
||||
footer_text: string | null
|
||||
impressum: string | null
|
||||
is_public: boolean
|
||||
navigation: Json
|
||||
primary_color: string | null
|
||||
secondary_color: string | null
|
||||
site_logo: string | null
|
||||
site_name: string | null
|
||||
social_links: Json | null
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
account_id: string
|
||||
contact_address?: string | null
|
||||
contact_email?: string | null
|
||||
contact_phone?: string | null
|
||||
created_at?: string
|
||||
custom_css?: string | null
|
||||
custom_domain?: string | null
|
||||
datenschutz?: string | null
|
||||
font_family?: string | null
|
||||
footer_text?: string | null
|
||||
impressum?: string | null
|
||||
is_public?: boolean
|
||||
navigation?: Json
|
||||
primary_color?: string | null
|
||||
secondary_color?: string | null
|
||||
site_logo?: string | null
|
||||
site_name?: string | null
|
||||
social_links?: Json | null
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
account_id?: string
|
||||
contact_address?: string | null
|
||||
contact_email?: string | null
|
||||
contact_phone?: string | null
|
||||
created_at?: string
|
||||
custom_css?: string | null
|
||||
custom_domain?: string | null
|
||||
datenschutz?: string | null
|
||||
font_family?: string | null
|
||||
footer_text?: string | null
|
||||
impressum?: string | null
|
||||
is_public?: boolean
|
||||
navigation?: Json
|
||||
primary_color?: string | null
|
||||
secondary_color?: string | null
|
||||
site_logo?: string | null
|
||||
site_name?: string | null
|
||||
social_links?: Json | null
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "site_settings_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: true
|
||||
referencedRelation: "accounts"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "site_settings_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: true
|
||||
referencedRelation: "user_account_workspace"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "site_settings_account_id_fkey"
|
||||
columns: ["account_id"]
|
||||
isOneToOne: true
|
||||
referencedRelation: "user_accounts"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
subscription_items: {
|
||||
Row: {
|
||||
created_at: string
|
||||
@@ -3194,6 +3951,22 @@ export type Database = {
|
||||
Args: { target_team_account_id: string; target_user_id: string }
|
||||
Returns: boolean
|
||||
}
|
||||
check_duplicate_member: {
|
||||
Args: {
|
||||
p_account_id: string
|
||||
p_date_of_birth?: string
|
||||
p_first_name: string
|
||||
p_last_name: string
|
||||
}
|
||||
Returns: {
|
||||
date_of_birth: string
|
||||
first_name: string
|
||||
id: string
|
||||
last_name: string
|
||||
member_number: string
|
||||
status: Database["public"]["Enums"]["membership_status"]
|
||||
}[]
|
||||
}
|
||||
create_invitation: {
|
||||
Args: { account_id: string; email: string; role: string }
|
||||
Returns: {
|
||||
@@ -3327,6 +4100,10 @@ export type Database = {
|
||||
Args: { account_id: string; user_id: string }
|
||||
Returns: boolean
|
||||
}
|
||||
link_member_to_user: {
|
||||
Args: { p_invite_token: string; p_user_id: string }
|
||||
Returns: string
|
||||
}
|
||||
module_query: {
|
||||
Args: {
|
||||
p_filters?: Json
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/site-builder": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@next/bundle-analyzer": "catalog:",
|
||||
"@tailwindcss/postcss": "catalog:",
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* -------------------------------------------------------
|
||||
* Member Management Parity Migration
|
||||
* Adds missing columns, departments, roles, honors,
|
||||
* SEPA mandates table, dues extensions, duplicate detection
|
||||
* -------------------------------------------------------
|
||||
*/
|
||||
|
||||
-- =====================================================
|
||||
-- A1. Add missing columns to members table
|
||||
-- =====================================================
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS salutation text;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS street2 text;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS phone2 text;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS fax text;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS birthplace text;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS birth_country text DEFAULT 'DE';
|
||||
|
||||
-- Membership lifecycle flags
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_honorary boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_founding_member boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_youth boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_retiree boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_probationary boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_transferred boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_archived boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- Youth/Guardian
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS guardian_name text;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS guardian_phone text;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS guardian_email text;
|
||||
|
||||
-- Dues tracking
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS dues_year integer;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS dues_paid boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS additional_fees numeric(10,2) DEFAULT 0;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS exemption_type text;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS exemption_reason text;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS exemption_amount numeric(10,2);
|
||||
|
||||
-- SEPA extras
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS sepa_mandate_reference text;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS sepa_mandate_sequence text DEFAULT 'RCUR';
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS sepa_bank_name text;
|
||||
|
||||
-- GDPR granular
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS gdpr_newsletter boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS gdpr_internet boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS gdpr_print boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS gdpr_birthday_info boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- Online portal
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS online_access_key text;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS online_access_blocked boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS email_confirmed boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- Address quality
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS address_invalid boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS data_reconciliation_needed boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- =====================================================
|
||||
-- A2. member_departments + assignments
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS public.member_departments (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_member_departments_account ON public.member_departments(account_id);
|
||||
ALTER TABLE public.member_departments ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.member_departments FROM authenticated, service_role;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_departments TO authenticated;
|
||||
GRANT ALL ON public.member_departments TO service_role;
|
||||
CREATE POLICY member_departments_select ON public.member_departments FOR SELECT TO authenticated
|
||||
USING (public.has_role_on_account(account_id));
|
||||
CREATE POLICY member_departments_mutate ON public.member_departments FOR ALL TO authenticated
|
||||
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.member_department_assignments (
|
||||
member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
|
||||
department_id uuid NOT NULL REFERENCES public.member_departments(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (member_id, department_id)
|
||||
);
|
||||
ALTER TABLE public.member_department_assignments ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.member_department_assignments FROM authenticated, service_role;
|
||||
GRANT SELECT, INSERT, DELETE ON public.member_department_assignments TO authenticated;
|
||||
GRANT ALL ON public.member_department_assignments TO service_role;
|
||||
CREATE POLICY mda_select ON public.member_department_assignments FOR SELECT TO authenticated
|
||||
USING (EXISTS (SELECT 1 FROM public.members m WHERE m.id = member_department_assignments.member_id AND public.has_role_on_account(m.account_id)));
|
||||
CREATE POLICY mda_mutate ON public.member_department_assignments FOR ALL TO authenticated
|
||||
USING (EXISTS (SELECT 1 FROM public.members m WHERE m.id = member_department_assignments.member_id AND public.has_permission(auth.uid(), m.account_id, 'members.write'::public.app_permissions)));
|
||||
|
||||
-- =====================================================
|
||||
-- A3. member_roles (board positions / Funktionen)
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS public.member_roles (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
|
||||
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
role_name text NOT NULL,
|
||||
from_date date,
|
||||
until_date date,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_member_roles_member ON public.member_roles(member_id);
|
||||
ALTER TABLE public.member_roles ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.member_roles FROM authenticated, service_role;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_roles TO authenticated;
|
||||
GRANT ALL ON public.member_roles TO service_role;
|
||||
CREATE POLICY member_roles_select ON public.member_roles FOR SELECT TO authenticated
|
||||
USING (public.has_role_on_account(account_id));
|
||||
CREATE POLICY member_roles_mutate ON public.member_roles FOR ALL TO authenticated
|
||||
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
|
||||
|
||||
-- =====================================================
|
||||
-- A4. member_honors (Ehrungen)
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS public.member_honors (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
|
||||
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
honor_name text NOT NULL,
|
||||
honor_date date,
|
||||
description text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_member_honors_member ON public.member_honors(member_id);
|
||||
ALTER TABLE public.member_honors ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.member_honors FROM authenticated, service_role;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_honors TO authenticated;
|
||||
GRANT ALL ON public.member_honors TO service_role;
|
||||
CREATE POLICY member_honors_select ON public.member_honors FOR SELECT TO authenticated
|
||||
USING (public.has_role_on_account(account_id));
|
||||
CREATE POLICY member_honors_mutate ON public.member_honors FOR ALL TO authenticated
|
||||
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
|
||||
|
||||
-- =====================================================
|
||||
-- A5. sepa_mandates (proper sub-table)
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS public.sepa_mandates (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
|
||||
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
mandate_reference text NOT NULL,
|
||||
iban text NOT NULL,
|
||||
bic text,
|
||||
account_holder text NOT NULL,
|
||||
mandate_date date NOT NULL,
|
||||
status public.sepa_mandate_status NOT NULL DEFAULT 'active',
|
||||
sequence text NOT NULL DEFAULT 'RCUR' CHECK (sequence IN ('FRST','RCUR','FNAL','OOFF')),
|
||||
is_primary boolean NOT NULL DEFAULT true,
|
||||
has_error boolean NOT NULL DEFAULT false,
|
||||
last_used_at timestamptz,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_sepa_mandates_member ON public.sepa_mandates(member_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_sepa_mandates_account ON public.sepa_mandates(account_id);
|
||||
ALTER TABLE public.sepa_mandates ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.sepa_mandates FROM authenticated, service_role;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.sepa_mandates TO authenticated;
|
||||
GRANT ALL ON public.sepa_mandates TO service_role;
|
||||
CREATE POLICY sepa_mandates_select ON public.sepa_mandates FOR SELECT TO authenticated
|
||||
USING (public.has_role_on_account(account_id));
|
||||
CREATE POLICY sepa_mandates_mutate ON public.sepa_mandates FOR ALL TO authenticated
|
||||
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
|
||||
|
||||
CREATE TRIGGER trg_sepa_mandates_updated_at
|
||||
BEFORE UPDATE ON public.sepa_mandates
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp();
|
||||
|
||||
-- =====================================================
|
||||
-- A6. Extend dues_categories
|
||||
-- =====================================================
|
||||
ALTER TABLE public.dues_categories ADD COLUMN IF NOT EXISTS is_youth boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE public.dues_categories ADD COLUMN IF NOT EXISTS is_exit boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- =====================================================
|
||||
-- A7. Duplicate detection function
|
||||
-- =====================================================
|
||||
CREATE OR REPLACE FUNCTION public.check_duplicate_member(
|
||||
p_account_id uuid,
|
||||
p_first_name text,
|
||||
p_last_name text,
|
||||
p_date_of_birth date DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE(id uuid, member_number text, first_name text, last_name text, date_of_birth date, status public.membership_status)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
SELECT m.id, m.member_number, m.first_name, m.last_name, m.date_of_birth, m.status
|
||||
FROM public.members m
|
||||
WHERE m.account_id = p_account_id
|
||||
AND lower(m.first_name) = lower(p_first_name)
|
||||
AND lower(m.last_name) = lower(p_last_name)
|
||||
AND (p_date_of_birth IS NULL OR m.date_of_birth = p_date_of_birth);
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.check_duplicate_member(uuid, text, text, date) TO authenticated, service_role;
|
||||
176
apps/web/supabase/migrations/20260410000001_site_builder.sql
Normal file
176
apps/web/supabase/migrations/20260410000001_site_builder.sql
Normal file
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Site Builder Migration
|
||||
* Tables: site_pages, site_settings, cms_posts, newsletter_subscriptions
|
||||
* Public read via anon + RLS
|
||||
*/
|
||||
|
||||
-- =====================================================
|
||||
-- 1. site_pages — Puck JSON per page
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS public.site_pages (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
slug text NOT NULL,
|
||||
title text NOT NULL,
|
||||
puck_data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
is_published boolean NOT NULL DEFAULT false,
|
||||
is_homepage boolean NOT NULL DEFAULT false,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
meta_description text,
|
||||
meta_image text,
|
||||
created_by uuid REFERENCES auth.users(id),
|
||||
updated_by uuid REFERENCES auth.users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
published_at timestamptz,
|
||||
UNIQUE(account_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_site_pages_account ON public.site_pages(account_id);
|
||||
CREATE INDEX idx_site_pages_published ON public.site_pages(account_id, is_published);
|
||||
|
||||
ALTER TABLE public.site_pages ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.site_pages FROM authenticated, service_role;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.site_pages TO authenticated;
|
||||
GRANT SELECT ON public.site_pages TO anon;
|
||||
GRANT ALL ON public.site_pages TO service_role;
|
||||
|
||||
CREATE POLICY site_pages_public_read ON public.site_pages
|
||||
FOR SELECT TO anon USING (is_published = true);
|
||||
|
||||
CREATE POLICY site_pages_auth_read ON public.site_pages
|
||||
FOR SELECT TO authenticated USING (
|
||||
is_published = true OR public.has_role_on_account(account_id)
|
||||
);
|
||||
|
||||
CREATE POLICY site_pages_admin_write ON public.site_pages
|
||||
FOR ALL TO authenticated USING (
|
||||
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_site_pages_updated
|
||||
BEFORE UPDATE ON public.site_pages
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp();
|
||||
|
||||
-- =====================================================
|
||||
-- 2. site_settings — Per-club branding
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS public.site_settings (
|
||||
account_id uuid PRIMARY KEY REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
site_name text,
|
||||
site_logo text,
|
||||
primary_color text DEFAULT '#2563eb',
|
||||
secondary_color text DEFAULT '#64748b',
|
||||
font_family text DEFAULT 'Inter',
|
||||
custom_css text,
|
||||
navigation jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
footer_text text,
|
||||
contact_email text,
|
||||
contact_phone text,
|
||||
contact_address text,
|
||||
social_links jsonb DEFAULT '{}'::jsonb,
|
||||
impressum text,
|
||||
datenschutz text,
|
||||
custom_domain text,
|
||||
is_public boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.site_settings ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.site_settings FROM authenticated, service_role;
|
||||
GRANT SELECT, INSERT, UPDATE ON public.site_settings TO authenticated;
|
||||
GRANT SELECT ON public.site_settings TO anon;
|
||||
GRANT ALL ON public.site_settings TO service_role;
|
||||
|
||||
CREATE POLICY site_settings_public_read ON public.site_settings
|
||||
FOR SELECT TO anon USING (is_public = true);
|
||||
|
||||
CREATE POLICY site_settings_auth_read ON public.site_settings
|
||||
FOR SELECT TO authenticated USING (
|
||||
is_public = true OR public.has_role_on_account(account_id)
|
||||
);
|
||||
|
||||
CREATE POLICY site_settings_admin_write ON public.site_settings
|
||||
FOR ALL TO authenticated USING (
|
||||
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_site_settings_updated
|
||||
BEFORE UPDATE ON public.site_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp();
|
||||
|
||||
-- =====================================================
|
||||
-- 3. cms_posts — News/blog per club
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS public.cms_posts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
slug text NOT NULL,
|
||||
content text,
|
||||
excerpt text,
|
||||
cover_image text,
|
||||
author_id uuid REFERENCES auth.users(id),
|
||||
status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published','archived')),
|
||||
published_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(account_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_cms_posts_account ON public.cms_posts(account_id);
|
||||
CREATE INDEX idx_cms_posts_published ON public.cms_posts(account_id, status);
|
||||
|
||||
ALTER TABLE public.cms_posts ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.cms_posts FROM authenticated, service_role;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.cms_posts TO authenticated;
|
||||
GRANT SELECT ON public.cms_posts TO anon;
|
||||
GRANT ALL ON public.cms_posts TO service_role;
|
||||
|
||||
CREATE POLICY cms_posts_public_read ON public.cms_posts
|
||||
FOR SELECT TO anon USING (status = 'published');
|
||||
|
||||
CREATE POLICY cms_posts_auth_read ON public.cms_posts
|
||||
FOR SELECT TO authenticated USING (
|
||||
status = 'published' OR public.has_role_on_account(account_id)
|
||||
);
|
||||
|
||||
CREATE POLICY cms_posts_admin_write ON public.cms_posts
|
||||
FOR ALL TO authenticated USING (
|
||||
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_cms_posts_updated
|
||||
BEFORE UPDATE ON public.cms_posts
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp();
|
||||
|
||||
-- =====================================================
|
||||
-- 4. newsletter_subscriptions — Public signup
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS public.newsletter_subscriptions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
email text NOT NULL,
|
||||
name text,
|
||||
subscribed_at timestamptz NOT NULL DEFAULT now(),
|
||||
unsubscribed_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
confirmation_token text,
|
||||
confirmed_at timestamptz,
|
||||
UNIQUE(account_id, email)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_newsletter_subs_account ON public.newsletter_subscriptions(account_id);
|
||||
|
||||
ALTER TABLE public.newsletter_subscriptions ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.newsletter_subscriptions FROM authenticated, service_role;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.newsletter_subscriptions TO authenticated;
|
||||
GRANT INSERT ON public.newsletter_subscriptions TO anon;
|
||||
GRANT ALL ON public.newsletter_subscriptions TO service_role;
|
||||
|
||||
CREATE POLICY newsletter_sub_public_insert ON public.newsletter_subscriptions
|
||||
FOR INSERT TO anon WITH CHECK (true);
|
||||
|
||||
CREATE POLICY newsletter_sub_admin ON public.newsletter_subscriptions
|
||||
FOR ALL TO authenticated USING (public.has_role_on_account(account_id));
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Fix: Grant anon USAGE on public schema for public page reads
|
||||
GRANT USAGE ON SCHEMA public TO anon;
|
||||
|
||||
-- Ensure anon can read accounts table (needed to resolve club slugs)
|
||||
GRANT SELECT ON public.accounts TO anon;
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Member Portal Auth + Invitations
|
||||
* Links members to auth.users, adds invitation system
|
||||
*/
|
||||
|
||||
-- Add user_id to members (links to Supabase Auth)
|
||||
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS ix_members_user ON public.members(user_id);
|
||||
|
||||
-- Member portal invitations
|
||||
CREATE TABLE IF NOT EXISTS public.member_portal_invitations (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
|
||||
email text NOT NULL,
|
||||
invite_token text NOT NULL DEFAULT gen_random_uuid()::text,
|
||||
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','accepted','expired','revoked')),
|
||||
invited_by uuid REFERENCES auth.users(id),
|
||||
accepted_at timestamptz,
|
||||
expires_at timestamptz NOT NULL DEFAULT (now() + interval '30 days'),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_portal_invitations_token ON public.member_portal_invitations(invite_token);
|
||||
CREATE INDEX IF NOT EXISTS ix_portal_invitations_member ON public.member_portal_invitations(member_id);
|
||||
|
||||
ALTER TABLE public.member_portal_invitations ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.member_portal_invitations FROM authenticated, service_role;
|
||||
GRANT SELECT, INSERT, UPDATE ON public.member_portal_invitations TO authenticated;
|
||||
GRANT ALL ON public.member_portal_invitations TO service_role;
|
||||
|
||||
-- Admins can manage invitations for their account
|
||||
CREATE POLICY portal_invitations_admin ON public.member_portal_invitations
|
||||
FOR ALL TO authenticated
|
||||
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
|
||||
|
||||
-- Anon can read invitation by token (for the accept flow)
|
||||
GRANT SELECT ON public.member_portal_invitations TO anon;
|
||||
CREATE POLICY portal_invitations_anon_read ON public.member_portal_invitations
|
||||
FOR SELECT TO anon
|
||||
USING (status = 'pending' AND expires_at > now());
|
||||
|
||||
-- RLS: Members can read their own portal data
|
||||
-- Allow authenticated users to read their own member record via user_id
|
||||
CREATE POLICY members_portal_self_read ON public.members
|
||||
FOR SELECT TO authenticated
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
-- Allow members to update their own contact/gdpr fields
|
||||
CREATE POLICY members_portal_self_update ON public.members
|
||||
FOR UPDATE TO authenticated
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
-- Add is_members_only flag to site_pages for member-only content
|
||||
ALTER TABLE public.site_pages ADD COLUMN IF NOT EXISTS is_members_only boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- Function: Link member to auth user after signup
|
||||
CREATE OR REPLACE FUNCTION public.link_member_to_user(
|
||||
p_invite_token text,
|
||||
p_user_id uuid
|
||||
) RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
v_member_id uuid;
|
||||
v_account_id uuid;
|
||||
BEGIN
|
||||
-- Find and validate invitation
|
||||
SELECT member_id, account_id INTO v_member_id, v_account_id
|
||||
FROM public.member_portal_invitations
|
||||
WHERE invite_token = p_invite_token
|
||||
AND status = 'pending'
|
||||
AND expires_at > now();
|
||||
|
||||
IF v_member_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Invalid or expired invitation';
|
||||
END IF;
|
||||
|
||||
-- Link member to user
|
||||
UPDATE public.members SET user_id = p_user_id WHERE id = v_member_id;
|
||||
|
||||
-- Mark invitation as accepted
|
||||
UPDATE public.member_portal_invitations
|
||||
SET status = 'accepted', accepted_at = now()
|
||||
WHERE invite_token = p_invite_token;
|
||||
|
||||
RETURN v_member_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.link_member_to_user(text, uuid) TO authenticated, service_role;
|
||||
Reference in New Issue
Block a user