feat: complete CMS v2 with Docker, Fischerei, Meetings, Verband modules + UX audit fixes
Major changes: - Docker Compose: full Supabase stack (11 services) equivalent to supabase CLI - Fischerei module: 16 DB tables, waters/species/stocking/catch books/competitions - Sitzungsprotokolle module: meeting protocols, agenda items, task tracking - Verbandsverwaltung module: federation management, member clubs, contacts, fees - Per-account module activation via Modules page toggle - Site Builder: live CMS data in Puck blocks (courses, events, membership registration) - Public registration APIs: course signup, event registration, membership application - Document generation: PDF member cards, Excel reports, HTML labels - Landing page: real Com.BISS content (no filler text) - UX audit fixes: AccountNotFound component, shared status badges, confirm dialog, pagination, duplicate heading removal, emoji→badge replacement, a11y fixes - QA: healthcheck fix, API auth fix, enum mismatch fix, password required attribute
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { SiteRenderer } from '@kit/site-builder/components';
|
||||
import type { SiteData } from '@kit/site-builder/context';
|
||||
|
||||
interface Props { params: Promise<{ slug: string; page: string[] }> }
|
||||
|
||||
@@ -23,9 +24,26 @@ export default async function ClubSubPage({ params }: Props) {
|
||||
.eq('account_id', account.id).eq('slug', pageSlug).eq('is_published', true).maybeSingle();
|
||||
if (!sitePageData) notFound();
|
||||
|
||||
// Pre-fetch CMS data for Puck components
|
||||
const [eventsRes, coursesRes, postsRes] = await Promise.all([
|
||||
supabase.from('events').select('id, name, event_date, event_time, location, fee, status')
|
||||
.eq('account_id', account.id).order('event_date', { ascending: true }).limit(20),
|
||||
supabase.from('courses').select('id, name, start_date, end_date, fee, capacity, status')
|
||||
.eq('account_id', account.id).order('start_date', { ascending: true }).limit(20),
|
||||
supabase.from('cms_posts').select('id, title, excerpt, cover_image, published_at, slug')
|
||||
.eq('account_id', account.id).eq('status', 'published').order('published_at', { ascending: false }).limit(20),
|
||||
]);
|
||||
|
||||
const siteData: SiteData = {
|
||||
accountId: account.id,
|
||||
events: eventsRes.data ?? [],
|
||||
courses: (coursesRes.data ?? []).map(c => ({ ...c, enrolled_count: 0 })),
|
||||
posts: postsRes.data ?? [],
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ '--primary': settings.primary_color, fontFamily: settings.font_family } as React.CSSProperties}>
|
||||
<SiteRenderer data={(sitePageData.puck_data ?? {}) as Record<string, unknown>} />
|
||||
<SiteRenderer data={(sitePageData.puck_data ?? {}) as Record<string, unknown>} siteData={siteData} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,48 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { SiteRenderer } from '@kit/site-builder/components';
|
||||
import type { SiteData } from '@kit/site-builder/context';
|
||||
|
||||
interface Props { params: Promise<{ slug: string }> }
|
||||
|
||||
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();
|
||||
|
||||
// Pre-fetch CMS data for Puck components
|
||||
const [eventsRes, coursesRes, postsRes] = await Promise.all([
|
||||
supabase.from('events').select('id, name, event_date, event_time, location, fee, status')
|
||||
.eq('account_id', account.id).order('event_date', { ascending: true }).limit(20),
|
||||
supabase.from('courses').select('id, name, start_date, end_date, fee, capacity, status')
|
||||
.eq('account_id', account.id).order('start_date', { ascending: true }).limit(20),
|
||||
supabase.from('cms_posts').select('id, title, excerpt, cover_image, published_at, slug')
|
||||
.eq('account_id', account.id).eq('status', 'published').order('published_at', { ascending: false }).limit(20),
|
||||
]);
|
||||
|
||||
const siteData: SiteData = {
|
||||
accountId: account.id,
|
||||
events: eventsRes.data ?? [],
|
||||
courses: (coursesRes.data ?? []).map(c => ({ ...c, enrolled_count: 0 })),
|
||||
posts: postsRes.data ?? [],
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ '--primary': settings.primary_color, '--secondary': settings.secondary_color, fontFamily: settings.font_family } as React.CSSProperties}>
|
||||
<SiteRenderer data={(page.puck_data ?? {}) as Record<string, unknown>} />
|
||||
<SiteRenderer data={(page.puck_data ?? {}) as Record<string, unknown>} siteData={siteData} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Home, LogOut, Menu } from 'lucide-react';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { AccountSelector } from '@kit/accounts/account-selector';
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
@@ -21,11 +22,11 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
|
||||
|
||||
type Accounts = Array<{
|
||||
label: string | null;
|
||||
@@ -43,11 +44,12 @@ export const TeamAccountLayoutMobileNavigation = (
|
||||
account: string;
|
||||
userId: string;
|
||||
accounts: Accounts;
|
||||
config: z.output<typeof NavigationConfigSchema>;
|
||||
}>,
|
||||
) => {
|
||||
const signOut = useSignOut();
|
||||
|
||||
const Links = getTeamAccountSidebarConfig(props.account).routes.map(
|
||||
const Links = props.config.routes.map(
|
||||
(item, index) => {
|
||||
if ('children' in item) {
|
||||
return item.children.map((child) => {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import { JWTUserData } from '@kit/supabase/types';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
|
||||
import { Sidebar, SidebarContent, SidebarHeader } from '@kit/ui/sidebar';
|
||||
|
||||
import type { AccountModel } from '~/components/workspace-dropdown';
|
||||
import { WorkspaceDropdown } from '~/components/workspace-dropdown';
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
|
||||
import { TeamAccountNotifications } from '~/home/[account]/_components/team-account-notifications';
|
||||
|
||||
import { TeamAccountLayoutSidebarNavigation } from './team-account-layout-sidebar-navigation';
|
||||
@@ -15,10 +17,10 @@ export function TeamAccountLayoutSidebar(props: {
|
||||
accountId: string;
|
||||
accounts: AccountModel[];
|
||||
user: JWTUserData;
|
||||
config: z.output<typeof NavigationConfigSchema>;
|
||||
}) {
|
||||
const { account, accounts, user } = props;
|
||||
const { account, accounts, user, config } = props;
|
||||
|
||||
const config = getTeamAccountSidebarConfig(account);
|
||||
const collapsible = config.sidebarCollapsedStyle;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import {
|
||||
BorderedNavigationMenu,
|
||||
BorderedNavigationMenuItem,
|
||||
} from '@kit/ui/bordered-navigation-menu';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
|
||||
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
|
||||
import { TeamAccountAccountsSelector } from '~/home/[account]/_components/team-account-accounts-selector';
|
||||
|
||||
// local imports
|
||||
@@ -16,10 +18,11 @@ import { TeamAccountNotifications } from './team-account-notifications';
|
||||
|
||||
export function TeamAccountNavigationMenu(props: {
|
||||
workspace: TeamAccountWorkspace;
|
||||
config: z.output<typeof NavigationConfigSchema>;
|
||||
}) {
|
||||
const { account, user, accounts } = props.workspace;
|
||||
|
||||
const routes = getTeamAccountSidebarConfig(account.slug).routes.reduce<
|
||||
const routes = props.config.routes.reduce<
|
||||
Array<{
|
||||
path: string;
|
||||
label: string;
|
||||
|
||||
@@ -21,9 +21,8 @@ import {
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
|
||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string; bookingId: string }>;
|
||||
@@ -60,9 +59,13 @@ export default async function BookingDetailPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createBookingManagementApi(client);
|
||||
if (!acct) {
|
||||
return (
|
||||
<CmsPageShell account={account} title="Buchungsdetails">
|
||||
<AccountNotFound />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
// Load booking directly
|
||||
const { data: booking } = await client
|
||||
@@ -117,7 +120,6 @@ export default async function BookingDetailPage({ params }: PageProps) {
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">Buchungsdetails</h1>
|
||||
<Badge variant={STATUS_BADGE_VARIANT[status] ?? 'secondary'}>
|
||||
{STATUS_LABEL[status] ?? status}
|
||||
</Badge>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -56,7 +57,13 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) {
|
||||
return (
|
||||
<CmsPageShell account={account} title="Belegungskalender">
|
||||
<AccountNotFound />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const api = createBookingManagementApi(client);
|
||||
|
||||
@@ -128,12 +135,9 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Belegungskalender</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Zimmerauslastung im Überblick
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Zimmerauslastung im Überblick
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -23,7 +24,13 @@ export default async function GuestsPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) {
|
||||
return (
|
||||
<CmsPageShell account={account} title="Gäste">
|
||||
<AccountNotFound />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const api = createBookingManagementApi(client);
|
||||
const guests = await api.listGuests(acct.id);
|
||||
@@ -32,10 +39,7 @@ export default async function GuestsPage({ params }: PageProps) {
|
||||
<CmsPageShell account={account} title="Gäste">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Gäste</h1>
|
||||
<p className="text-muted-foreground">Gästeverwaltung</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground">Gästeverwaltung</p>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Gast
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
import { CreateBookingForm } from '@kit/booking-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
|
||||
@@ -9,7 +10,13 @@ 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();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) {
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neue Buchung">
|
||||
<AccountNotFound />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const api = createBookingManagementApi(client);
|
||||
const rooms = await api.listRooms(acct.id);
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { BedDouble, CalendarCheck, Plus, Euro } from 'lucide-react';
|
||||
import { BedDouble, CalendarCheck, Plus, Euro, Search } 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 { Input } from '@kit/ui/input';
|
||||
|
||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const STATUS_BADGE_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
@@ -37,8 +42,9 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
no_show: 'Nicht erschienen',
|
||||
};
|
||||
|
||||
export default async function BookingsPage({ params }: PageProps) {
|
||||
export default async function BookingsPage({ params, searchParams }: PageProps) {
|
||||
const { account } = await params;
|
||||
const search = await searchParams;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
@@ -47,31 +53,70 @@ export default async function BookingsPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) {
|
||||
return (
|
||||
<CmsPageShell account={account} title="Buchungen">
|
||||
<AccountNotFound />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const searchQuery = typeof search.q === 'string' ? search.q : '';
|
||||
const page = Number(search.page) || 1;
|
||||
|
||||
const api = createBookingManagementApi(client);
|
||||
const rooms = await api.listRooms(acct.id);
|
||||
|
||||
const [rooms, bookings] = await Promise.all([
|
||||
api.listRooms(acct.id),
|
||||
api.listBookings(acct.id, { page: 1 }),
|
||||
]);
|
||||
// Fetch bookings with joined room & guest names (avoids displaying raw UUIDs)
|
||||
const bookingsQuery = client
|
||||
.from('bookings')
|
||||
.select(
|
||||
'*, room:rooms(id, room_number, name), guest:guests(id, first_name, last_name)',
|
||||
{ count: 'exact' },
|
||||
)
|
||||
.eq('account_id', acct.id)
|
||||
.order('check_in', { ascending: false })
|
||||
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
|
||||
|
||||
const activeBookings = bookings.data.filter(
|
||||
(b: Record<string, unknown>) =>
|
||||
b.status === 'confirmed' || b.status === 'checked_in',
|
||||
const { data: bookingsRaw, count: bookingsTotal } = await bookingsQuery;
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
let bookingsData = (bookingsRaw ?? []) as Array<Record<string, any>>;
|
||||
const total = bookingsTotal ?? 0;
|
||||
|
||||
// Post-filter by search query (guest name or room name/number)
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
bookingsData = bookingsData.filter((b) => {
|
||||
const room = b.room as Record<string, string> | null;
|
||||
const guest = b.guest as Record<string, string> | null;
|
||||
const roomName = (room?.name ?? '').toLowerCase();
|
||||
const roomNumber = (room?.room_number ?? '').toLowerCase();
|
||||
const guestFirst = (guest?.first_name ?? '').toLowerCase();
|
||||
const guestLast = (guest?.last_name ?? '').toLowerCase();
|
||||
return (
|
||||
roomName.includes(q) ||
|
||||
roomNumber.includes(q) ||
|
||||
guestFirst.includes(q) ||
|
||||
guestLast.includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const activeBookings = bookingsData.filter(
|
||||
(b) => b.status === 'confirmed' || b.status === 'checked_in',
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Buchungen">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Buchungen</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Zimmer und Buchungen verwalten
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Zimmer und Buchungen verwalten
|
||||
</p>
|
||||
|
||||
<Link href={`/home/${account}/bookings/new`}>
|
||||
<Button>
|
||||
@@ -95,24 +140,61 @@ export default async function BookingsPage({ params }: PageProps) {
|
||||
/>
|
||||
<StatsCard
|
||||
title="Gesamt"
|
||||
value={bookings.total}
|
||||
value={total}
|
||||
icon={<Euro className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<form className="flex items-center gap-2">
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
name="q"
|
||||
defaultValue={searchQuery}
|
||||
placeholder="Gast oder Zimmer suchen…"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
Suchen
|
||||
</Button>
|
||||
{searchQuery && (
|
||||
<Link href={`/home/${account}/bookings`}>
|
||||
<Button type="button" variant="ghost" size="sm">
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Table or Empty State */}
|
||||
{bookings.data.length === 0 ? (
|
||||
{bookingsData.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<BedDouble className="h-8 w-8" />}
|
||||
title="Keine Buchungen vorhanden"
|
||||
description="Erstellen Sie Ihre erste Buchung, um loszulegen."
|
||||
actionLabel="Neue Buchung"
|
||||
actionHref={`/home/${account}/bookings/new`}
|
||||
title={
|
||||
searchQuery
|
||||
? 'Keine Buchungen gefunden'
|
||||
: 'Keine Buchungen vorhanden'
|
||||
}
|
||||
description={
|
||||
searchQuery
|
||||
? `Keine Ergebnisse für „${searchQuery}".`
|
||||
: 'Erstellen Sie Ihre erste Buchung, um loszulegen.'
|
||||
}
|
||||
actionLabel={searchQuery ? undefined : 'Neue Buchung'}
|
||||
actionHref={
|
||||
searchQuery ? undefined : `/home/${account}/bookings/new`
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Buchungen ({bookings.total})</CardTitle>
|
||||
<CardTitle>
|
||||
{searchQuery
|
||||
? `Ergebnisse (${bookingsData.length})`
|
||||
: `Alle Buchungen (${total})`}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
@@ -128,51 +210,104 @@ export default async function BookingsPage({ params }: PageProps) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bookings.data.map((booking: Record<string, unknown>) => (
|
||||
<tr
|
||||
key={String(booking.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
>
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/home/${account}/bookings/${String(booking.id)}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{String(booking.room_id ?? '—')}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{String(booking.guest_id ?? '—')}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{booking.check_in
|
||||
? new Date(String(booking.check_in)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{booking.check_out
|
||||
? new Date(String(booking.check_out)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
STATUS_BADGE_VARIANT[String(booking.status)] ?? 'secondary'
|
||||
}
|
||||
>
|
||||
{STATUS_LABEL[String(booking.status)] ?? String(booking.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{booking.total_price != null
|
||||
? `${Number(booking.total_price).toFixed(2)} €`
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{bookingsData.map((booking) => {
|
||||
const room = booking.room as Record<string, string> | null;
|
||||
const guest = booking.guest as Record<string, string> | null;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={String(booking.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
>
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/home/${account}/bookings/${String(booking.id)}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{room
|
||||
? `${room.room_number}${room.name ? ` – ${room.name}` : ''}`
|
||||
: '—'}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{guest
|
||||
? `${guest.first_name} ${guest.last_name}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{booking.check_in
|
||||
? new Date(
|
||||
String(booking.check_in),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{booking.check_out
|
||||
? new Date(
|
||||
String(booking.check_out),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
STATUS_BADGE_VARIANT[String(booking.status)] ??
|
||||
'secondary'
|
||||
}
|
||||
>
|
||||
{STATUS_LABEL[String(booking.status)] ??
|
||||
String(booking.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{booking.total_price != null
|
||||
? `${Number(booking.total_price).toFixed(2)} €`
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && !searchQuery && (
|
||||
<div className="flex items-center justify-between border-t px-2 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Seite {page} von {totalPages} ({total} Einträge)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{page > 1 ? (
|
||||
<Link
|
||||
href={`/home/${account}/bookings?page=${page - 1}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
Zurück
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Zurück
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{page < totalPages ? (
|
||||
<Link
|
||||
href={`/home/${account}/bookings?page=${page + 1}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
Weiter
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Weiter
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -24,7 +25,13 @@ export default async function RoomsPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) {
|
||||
return (
|
||||
<CmsPageShell account={account} title="Zimmer">
|
||||
<AccountNotFound />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const api = createBookingManagementApi(client);
|
||||
const rooms = await api.listRooms(acct.id);
|
||||
@@ -33,10 +40,7 @@ export default async function RoomsPage({ params }: PageProps) {
|
||||
<CmsPageShell account={account} title="Zimmer">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Zimmer</h1>
|
||||
<p className="text-muted-foreground">Zimmerverwaltung</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground">Zimmerverwaltung</p>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neues Zimmer
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -51,7 +52,7 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createCourseManagementApi(client);
|
||||
const courses = await api.listCourses(acct.id, { page: 1, pageSize: 100 });
|
||||
@@ -119,12 +120,9 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Kurskalender</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Kurstermine im Überblick
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Kurstermine im Überblick
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -23,7 +24,7 @@ export default async function CategoriesPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createCourseManagementApi(client);
|
||||
const categories = await api.listCategories(acct.id);
|
||||
@@ -32,10 +33,7 @@ export default async function CategoriesPage({ params }: PageProps) {
|
||||
<CmsPageShell account={account} title="Kategorien">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Kategorien</h1>
|
||||
<p className="text-muted-foreground">Kurskategorien verwalten</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground">Kurskategorien verwalten</p>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neue Kategorie
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -23,7 +24,7 @@ export default async function InstructorsPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createCourseManagementApi(client);
|
||||
const instructors = await api.listInstructors(acct.id);
|
||||
@@ -32,10 +33,7 @@ export default async function InstructorsPage({ params }: PageProps) {
|
||||
<CmsPageShell account={account} title="Dozenten">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Dozenten</h1>
|
||||
<p className="text-muted-foreground">Dozentenpool verwalten</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground">Dozentenpool verwalten</p>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Dozent
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -23,7 +24,7 @@ export default async function LocationsPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createCourseManagementApi(client);
|
||||
const locations = await api.listLocations(acct.id);
|
||||
@@ -32,10 +33,7 @@ export default async function LocationsPage({ params }: PageProps) {
|
||||
<CmsPageShell account={account} title="Orte">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Orte</h1>
|
||||
<p className="text-muted-foreground">Kurs- und Veranstaltungsorte verwalten</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground">Kurs- und Veranstaltungsorte verwalten</p>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Ort
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { CreateCourseForm } from '@kit/course-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
|
||||
@@ -8,7 +9,7 @@ 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>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neuer Kurs" description="Kurs anlegen">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { GraduationCap, Plus, Users, Calendar, Euro } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, GraduationCap, Plus, Users, Calendar, Euro } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
@@ -12,32 +12,19 @@ import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { COURSE_STATUS_VARIANT, COURSE_STATUS_LABEL } from '~/lib/status-badges';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
const STATUS_BADGE_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
planned: 'secondary',
|
||||
open: 'default',
|
||||
running: 'info',
|
||||
completed: 'outline',
|
||||
cancelled: 'destructive',
|
||||
};
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
planned: 'Geplant',
|
||||
open: 'Offen',
|
||||
running: 'Laufend',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Abgesagt',
|
||||
};
|
||||
|
||||
export default async function CoursesPage({ params }: PageProps) {
|
||||
export default async function CoursesPage({ params, searchParams }: PageProps) {
|
||||
const { account } = await params;
|
||||
const search = await searchParams;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
@@ -46,26 +33,26 @@ export default async function CoursesPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createCourseManagementApi(client);
|
||||
const page = Number(search.page) || 1;
|
||||
|
||||
const [courses, stats] = await Promise.all([
|
||||
api.listCourses(acct.id, { page: 1 }),
|
||||
api.listCourses(acct.id, { page, pageSize: PAGE_SIZE }),
|
||||
api.getStatistics(acct.id),
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(courses.total / PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Kurse">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Kurse</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Kursangebot verwalten
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Kursangebot verwalten
|
||||
</p>
|
||||
|
||||
<Link href={`/home/${account}/courses/new`}>
|
||||
<Button>
|
||||
@@ -123,7 +110,7 @@ export default async function CoursesPage({ params }: PageProps) {
|
||||
<th className="p-3 text-left font-medium">Beginn</th>
|
||||
<th className="p-3 text-left font-medium">Ende</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-right font-medium">Teilnehmer</th>
|
||||
<th className="p-3 text-right font-medium">Kapazität</th>
|
||||
<th className="p-3 text-right font-medium">Gebühr</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -153,13 +140,15 @@ export default async function CoursesPage({ params }: PageProps) {
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={STATUS_BADGE_VARIANT[String(course.status)] ?? 'secondary'}
|
||||
variant={COURSE_STATUS_VARIANT[String(course.status)] ?? 'secondary'}
|
||||
>
|
||||
{STATUS_LABEL[String(course.status)] ?? String(course.status)}
|
||||
{COURSE_STATUS_LABEL[String(course.status)] ?? String(course.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{String(course.capacity ?? '—')}
|
||||
{course.capacity != null
|
||||
? String(course.capacity)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{course.fee != null
|
||||
@@ -171,6 +160,44 @@ export default async function CoursesPage({ params }: PageProps) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t px-2 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Seite {page} von {totalPages} ({courses.total} Einträge)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{page > 1 ? (
|
||||
<Link href={`/home/${account}/courses?page=${page - 1}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Zurück
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Zurück
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{page < totalPages ? (
|
||||
<Link href={`/home/${account}/courses?page=${page + 1}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
Weiter
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Weiter
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,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';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -18,7 +19,7 @@ export default async function CourseStatisticsPage({ params }: PageProps) {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createCourseManagementApi(client);
|
||||
const stats = await api.getStatistics(acct.id);
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { FileDown, Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
|
||||
import {
|
||||
generateDocumentAction,
|
||||
type GenerateDocumentInput,
|
||||
type GenerateDocumentResult,
|
||||
} from '../_lib/server/generate-document';
|
||||
|
||||
interface Props {
|
||||
accountSlug: string;
|
||||
initialType: string;
|
||||
}
|
||||
|
||||
const DOCUMENT_LABELS: Record<string, string> = {
|
||||
'member-card': 'Mitgliedsausweis',
|
||||
invoice: 'Rechnung',
|
||||
labels: 'Etiketten',
|
||||
report: 'Bericht',
|
||||
letter: 'Brief',
|
||||
certificate: 'Zertifikat',
|
||||
};
|
||||
|
||||
const COMING_SOON_TYPES = new Set(['invoice', 'letter', 'certificate']);
|
||||
|
||||
export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [result, setResult] = useState<GenerateDocumentResult | null>(null);
|
||||
const [selectedType, setSelectedType] = useState(initialType);
|
||||
|
||||
const isComingSoon = COMING_SOON_TYPES.has(selectedType);
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setResult(null);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const input: GenerateDocumentInput = {
|
||||
accountSlug,
|
||||
documentType: formData.get('documentType') as string,
|
||||
title: formData.get('title') as string,
|
||||
format: formData.get('format') as 'A4' | 'A5' | 'letter',
|
||||
orientation: formData.get('orientation') as 'portrait' | 'landscape',
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
const res = await generateDocumentAction(input);
|
||||
setResult(res);
|
||||
|
||||
if (res.success && res.data && res.mimeType && res.fileName) {
|
||||
downloadFile(res.data, res.mimeType, res.fileName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
{/* Document Type */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="documentType">Dokumenttyp</Label>
|
||||
<select
|
||||
id="documentType"
|
||||
name="documentType"
|
||||
value={selectedType}
|
||||
onChange={(e) => {
|
||||
setSelectedType(e.target.value);
|
||||
setResult(null);
|
||||
}}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="member-card">Mitgliedsausweis</option>
|
||||
<option value="invoice">Rechnung</option>
|
||||
<option value="labels">Etiketten</option>
|
||||
<option value="report">Bericht</option>
|
||||
<option value="letter">Brief</option>
|
||||
<option value="certificate">Zertifikat</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Coming soon banner */}
|
||||
{isComingSoon && (
|
||||
<div className="flex items-start gap-3 rounded-md border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Demnächst verfügbar</p>
|
||||
<p className="mt-1 text-amber-700 dark:text-amber-300">
|
||||
Die Generierung von “{DOCUMENT_LABELS[selectedType]}”
|
||||
befindet sich noch in Entwicklung und wird in Kürze verfügbar sein.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="title">Titel / Bezeichnung</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
placeholder={`z.B. ${DOCUMENT_LABELS[selectedType] ?? 'Dokument'} ${new Date().getFullYear()}`}
|
||||
required
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Format & Orientation */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="format">Format</Label>
|
||||
<select
|
||||
id="format"
|
||||
name="format"
|
||||
disabled={isPending}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50"
|
||||
>
|
||||
<option value="A4">A4</option>
|
||||
<option value="A5">A5</option>
|
||||
<option value="letter">Letter</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="orientation">Ausrichtung</Label>
|
||||
<select
|
||||
id="orientation"
|
||||
name="orientation"
|
||||
disabled={isPending}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50"
|
||||
>
|
||||
<option value="portrait">Hochformat</option>
|
||||
<option value="landscape">Querformat</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hint */}
|
||||
<div className="text-muted-foreground rounded-md bg-muted/50 p-4 text-sm">
|
||||
<p>
|
||||
<strong>Hinweis:</strong>{' '}
|
||||
{selectedType === 'member-card'
|
||||
? 'Es werden Mitgliedsausweise für alle aktiven Mitglieder generiert (4 Karten pro A4-Seite).'
|
||||
: selectedType === 'labels'
|
||||
? 'Es werden Adressetiketten im Avery-L7163-Format für alle aktiven Mitglieder erzeugt.'
|
||||
: selectedType === 'report'
|
||||
? 'Es wird eine Excel-Datei mit allen Mitgliederdaten erstellt.'
|
||||
: 'Wählen Sie den gewünschten Dokumenttyp, um die Generierung zu starten.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Result feedback */}
|
||||
{result && !result.success && (
|
||||
<div className="flex items-start gap-3 rounded-md border border-red-200 bg-red-50 p-4 text-sm text-red-800 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Fehler bei der Generierung</p>
|
||||
<p className="mt-1">{result.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && result.success && (
|
||||
<div className="flex items-start gap-3 rounded-md border border-green-200 bg-green-50 p-4 text-sm text-green-800 dark:border-green-800 dark:bg-green-950/40 dark:text-green-200">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Dokument erfolgreich erstellt!</p>
|
||||
<p className="mt-1">
|
||||
Die Datei “{result.fileName}” wurde heruntergeladen.
|
||||
</p>
|
||||
{result.data && result.mimeType && result.fileName && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 text-green-700 underline hover:text-green-900 dark:text-green-300 dark:hover:text-green-100"
|
||||
onClick={() =>
|
||||
downloadFile(result.data!, result.mimeType!, result.fileName!)
|
||||
}
|
||||
>
|
||||
Erneut herunterladen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending || isComingSoon}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Wird generiert…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
Generieren
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download from a base64 string.
|
||||
* Uses an anchor element with the download attribute set to the full filename.
|
||||
*/
|
||||
function downloadFile(
|
||||
base64Data: string,
|
||||
mimeType: string,
|
||||
fileName: string,
|
||||
) {
|
||||
const byteCharacters = atob(base64Data);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], { type: mimeType });
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
// Ensure the filename always has the right extension
|
||||
a.download = fileName;
|
||||
// Force the filename by also setting it via the Content-Disposition-like attribute
|
||||
a.setAttribute('download', fileName);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
// Small delay before cleanup to ensure download starts
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
'use server';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createDocumentGeneratorApi } from '@kit/document-generator/api';
|
||||
|
||||
export type GenerateDocumentInput = {
|
||||
accountSlug: string;
|
||||
documentType: string;
|
||||
title: string;
|
||||
format: 'A4' | 'A5' | 'letter';
|
||||
orientation: 'portrait' | 'landscape';
|
||||
};
|
||||
|
||||
export type GenerateDocumentResult = {
|
||||
success: boolean;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
fileName?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function generateDocumentAction(
|
||||
input: GenerateDocumentInput,
|
||||
): Promise<GenerateDocumentResult> {
|
||||
try {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct, error: acctError } = await client
|
||||
.from('accounts')
|
||||
.select('id, name')
|
||||
.eq('slug', input.accountSlug)
|
||||
.single();
|
||||
|
||||
if (acctError || !acct) {
|
||||
return { success: false, error: 'Konto nicht gefunden.' };
|
||||
}
|
||||
|
||||
switch (input.documentType) {
|
||||
case 'member-card':
|
||||
return await generateMemberCards(client, acct.id, acct.name, input);
|
||||
case 'labels':
|
||||
return await generateLabels(client, acct.id, input);
|
||||
case 'report':
|
||||
return await generateMemberReport(client, acct.id, input);
|
||||
case 'invoice':
|
||||
case 'letter':
|
||||
case 'certificate':
|
||||
return {
|
||||
success: false,
|
||||
error: `"${LABELS[input.documentType] ?? input.documentType}" ist noch in Entwicklung.`,
|
||||
};
|
||||
default:
|
||||
return { success: false, error: 'Unbekannter Dokumenttyp.' };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Document generation error:', err);
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Unbekannter Fehler.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
'member-card': 'Mitgliedsausweis',
|
||||
invoice: 'Rechnung',
|
||||
labels: 'Etiketten',
|
||||
report: 'Bericht',
|
||||
letter: 'Brief',
|
||||
certificate: 'Zertifikat',
|
||||
};
|
||||
|
||||
function fmtDate(d: string | null): string {
|
||||
if (!d) return '–';
|
||||
try { return new Date(d).toLocaleDateString('de-DE'); } catch { return d; }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Member Card PDF — premium design with color accent bar, structured layout
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
async function generateMemberCards(
|
||||
client: ReturnType<typeof getSupabaseServerClient>,
|
||||
accountId: string,
|
||||
accountName: string,
|
||||
input: GenerateDocumentInput,
|
||||
): Promise<GenerateDocumentResult> {
|
||||
const { data: members, error } = await client
|
||||
.from('members')
|
||||
.select('id, member_number, first_name, last_name, entry_date, status, date_of_birth, street, house_number, postal_code, city, email')
|
||||
.eq('account_id', accountId)
|
||||
.eq('status', 'active')
|
||||
.order('last_name');
|
||||
|
||||
if (error) return { success: false, error: `DB-Fehler: ${error.message}` };
|
||||
if (!members?.length) return { success: false, error: 'Keine aktiven Mitglieder.' };
|
||||
|
||||
const { Document, Page, View, Text, StyleSheet, renderToBuffer, Svg, Rect, Circle } =
|
||||
await import('@react-pdf/renderer');
|
||||
|
||||
// — Brand colors (configurable later via account settings) —
|
||||
const PRIMARY = '#1e40af';
|
||||
const PRIMARY_LIGHT = '#dbeafe';
|
||||
const DARK = '#0f172a';
|
||||
const GRAY = '#64748b';
|
||||
const LIGHT_GRAY = '#f1f5f9';
|
||||
|
||||
const s = StyleSheet.create({
|
||||
page: { padding: 24, flexDirection: 'row', flexWrap: 'wrap', gap: 16, fontFamily: 'Helvetica' },
|
||||
|
||||
// ── Card shell ──
|
||||
card: {
|
||||
width: '47%',
|
||||
height: '45%',
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
border: `1pt solid ${PRIMARY_LIGHT}`,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
|
||||
// ── Top accent bar ──
|
||||
accentBar: { height: 6, backgroundColor: PRIMARY },
|
||||
|
||||
// ── Header area ──
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
clubName: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: PRIMARY },
|
||||
badge: {
|
||||
backgroundColor: PRIMARY_LIGHT,
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
badgeText: { fontSize: 6, color: PRIMARY, fontFamily: 'Helvetica-Bold', textTransform: 'uppercase' as const, letterSpacing: 0.8 },
|
||||
|
||||
// ── Main content ──
|
||||
body: { flexDirection: 'row', paddingHorizontal: 14, paddingTop: 8, gap: 12, flex: 1 },
|
||||
|
||||
// Photo column
|
||||
photoCol: { width: 64, alignItems: 'center' },
|
||||
photoFrame: {
|
||||
width: 56,
|
||||
height: 68,
|
||||
borderRadius: 6,
|
||||
backgroundColor: LIGHT_GRAY,
|
||||
border: `0.5pt solid #e2e8f0`,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
photoIcon: { fontSize: 20, color: '#cbd5e1' },
|
||||
memberNumber: {
|
||||
marginTop: 4,
|
||||
fontSize: 7,
|
||||
color: PRIMARY,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
|
||||
// Info column
|
||||
infoCol: { flex: 1, justifyContent: 'center' },
|
||||
memberName: { fontSize: 14, fontFamily: 'Helvetica-Bold', color: DARK, marginBottom: 6 },
|
||||
|
||||
fieldGroup: { flexDirection: 'row', flexWrap: 'wrap', gap: 4 },
|
||||
field: { width: '48%', marginBottom: 5 },
|
||||
fieldLabel: { fontSize: 6, color: GRAY, textTransform: 'uppercase' as const, letterSpacing: 0.6, marginBottom: 1 },
|
||||
fieldValue: { fontSize: 8, color: DARK, fontFamily: 'Helvetica-Bold' },
|
||||
|
||||
// ── Footer ──
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: LIGHT_GRAY,
|
||||
borderTop: `0.5pt solid #e2e8f0`,
|
||||
},
|
||||
footerLeft: { fontSize: 6, color: GRAY },
|
||||
footerRight: { fontSize: 6, color: GRAY },
|
||||
validDot: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: '#22c55e', marginRight: 3 },
|
||||
});
|
||||
|
||||
const today = new Date().toLocaleDateString('de-DE');
|
||||
const year = new Date().getFullYear();
|
||||
const cardsPerPage = 4;
|
||||
const pages: React.ReactElement[] = [];
|
||||
|
||||
for (let i = 0; i < members.length; i += cardsPerPage) {
|
||||
const batch = members.slice(i, i + cardsPerPage);
|
||||
|
||||
pages.push(
|
||||
React.createElement(
|
||||
Page,
|
||||
{ key: `p${i}`, size: input.format === 'letter' ? 'LETTER' : (input.format.toUpperCase() as 'A4'|'A5'), orientation: input.orientation, style: s.page },
|
||||
...batch.map((m) =>
|
||||
React.createElement(View, { key: m.id, style: s.card },
|
||||
// Accent bar
|
||||
React.createElement(View, { style: s.accentBar }),
|
||||
|
||||
// Header
|
||||
React.createElement(View, { style: s.header },
|
||||
React.createElement(Text, { style: s.clubName }, accountName),
|
||||
React.createElement(View, { style: s.badge },
|
||||
React.createElement(Text, { style: s.badgeText }, 'Mitgliedsausweis'),
|
||||
),
|
||||
),
|
||||
|
||||
// Body: photo + info
|
||||
React.createElement(View, { style: s.body },
|
||||
// Photo column
|
||||
React.createElement(View, { style: s.photoCol },
|
||||
React.createElement(View, { style: s.photoFrame },
|
||||
React.createElement(Text, { style: s.photoIcon }, '👤'),
|
||||
),
|
||||
React.createElement(Text, { style: s.memberNumber }, `Nr. ${m.member_number ?? '–'}`),
|
||||
),
|
||||
|
||||
// Info column
|
||||
React.createElement(View, { style: s.infoCol },
|
||||
React.createElement(Text, { style: s.memberName }, `${m.first_name} ${m.last_name}`),
|
||||
React.createElement(View, { style: s.fieldGroup },
|
||||
// Entry date
|
||||
React.createElement(View, { style: s.field },
|
||||
React.createElement(Text, { style: s.fieldLabel }, 'Mitglied seit'),
|
||||
React.createElement(Text, { style: s.fieldValue }, fmtDate(m.entry_date)),
|
||||
),
|
||||
// Date of birth
|
||||
React.createElement(View, { style: s.field },
|
||||
React.createElement(Text, { style: s.fieldLabel }, 'Geb.-Datum'),
|
||||
React.createElement(Text, { style: s.fieldValue }, fmtDate(m.date_of_birth)),
|
||||
),
|
||||
// Address
|
||||
React.createElement(View, { style: { ...s.field, width: '100%' } },
|
||||
React.createElement(Text, { style: s.fieldLabel }, 'Adresse'),
|
||||
React.createElement(Text, { style: s.fieldValue },
|
||||
[m.street, m.house_number].filter(Boolean).join(' ') || '–',
|
||||
),
|
||||
React.createElement(Text, { style: { ...s.fieldValue, marginTop: 1 } },
|
||||
[m.postal_code, m.city].filter(Boolean).join(' ') || '',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Footer
|
||||
React.createElement(View, { style: s.footer },
|
||||
React.createElement(View, { style: { flexDirection: 'row', alignItems: 'center' } },
|
||||
React.createElement(View, { style: s.validDot }),
|
||||
React.createElement(Text, { style: s.footerLeft }, `Gültig ${year}/${year + 1}`),
|
||||
),
|
||||
React.createElement(Text, { style: s.footerRight }, `Ausgestellt ${today}`),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const doc = React.createElement(Document, { title: input.title }, ...pages);
|
||||
const buffer = await renderToBuffer(doc);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: Buffer.from(buffer).toString('base64'),
|
||||
mimeType: 'application/pdf',
|
||||
fileName: `${input.title || 'Mitgliedsausweise'}.pdf`,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Address Labels (HTML — Avery L7163)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
async function generateLabels(
|
||||
client: ReturnType<typeof getSupabaseServerClient>,
|
||||
accountId: string,
|
||||
input: GenerateDocumentInput,
|
||||
): Promise<GenerateDocumentResult> {
|
||||
const { data: members, error } = await client
|
||||
.from('members')
|
||||
.select('first_name, last_name, street, house_number, postal_code, city, salutation, title')
|
||||
.eq('account_id', accountId)
|
||||
.eq('status', 'active')
|
||||
.order('last_name');
|
||||
|
||||
if (error) return { success: false, error: `DB-Fehler: ${error.message}` };
|
||||
if (!members?.length) return { success: false, error: 'Keine aktiven Mitglieder.' };
|
||||
|
||||
const api = createDocumentGeneratorApi();
|
||||
const records = members.map((m) => ({
|
||||
line1: [m.salutation, m.title, m.first_name, m.last_name].filter(Boolean).join(' '),
|
||||
line2: [m.street, m.house_number].filter(Boolean).join(' ') || undefined,
|
||||
line3: [m.postal_code, m.city].filter(Boolean).join(' ') || undefined,
|
||||
}));
|
||||
|
||||
const html = api.generateLabelsHtml({ labelFormat: 'avery-l7163', records });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: Buffer.from(html, 'utf-8').toString('base64'),
|
||||
mimeType: 'text/html',
|
||||
fileName: `${input.title || 'Adressetiketten'}.html`,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Member Report (Excel)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
async function generateMemberReport(
|
||||
client: ReturnType<typeof getSupabaseServerClient>,
|
||||
accountId: string,
|
||||
input: GenerateDocumentInput,
|
||||
): Promise<GenerateDocumentResult> {
|
||||
const { data: members, error } = await client
|
||||
.from('members')
|
||||
.select('member_number, last_name, first_name, email, postal_code, city, status, entry_date')
|
||||
.eq('account_id', accountId)
|
||||
.order('last_name');
|
||||
|
||||
if (error) return { success: false, error: `DB-Fehler: ${error.message}` };
|
||||
if (!members?.length) return { success: false, error: 'Keine Mitglieder.' };
|
||||
|
||||
const ExcelJS = await import('exceljs');
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = 'MyEasyCMS';
|
||||
wb.created = new Date();
|
||||
|
||||
const ws = wb.addWorksheet('Mitglieder');
|
||||
ws.columns = [
|
||||
{ header: 'Nr.', key: 'nr', width: 12 },
|
||||
{ header: 'Name', key: 'name', width: 20 },
|
||||
{ header: 'Vorname', key: 'vorname', width: 20 },
|
||||
{ header: 'E-Mail', key: 'email', width: 28 },
|
||||
{ header: 'PLZ', key: 'plz', width: 10 },
|
||||
{ header: 'Ort', key: 'ort', width: 20 },
|
||||
{ header: 'Status', key: 'status', width: 12 },
|
||||
{ header: 'Eintritt', key: 'eintritt', width: 14 },
|
||||
];
|
||||
|
||||
const hdr = ws.getRow(1);
|
||||
hdr.font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
hdr.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E40AF' } };
|
||||
hdr.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||
hdr.height = 24;
|
||||
|
||||
const SL: Record<string, string> = { active: 'Aktiv', inactive: 'Inaktiv', pending: 'Ausstehend', resigned: 'Ausgetreten', excluded: 'Ausgeschlossen' };
|
||||
|
||||
for (const m of members) {
|
||||
ws.addRow({
|
||||
nr: m.member_number ?? '',
|
||||
name: m.last_name,
|
||||
vorname: m.first_name,
|
||||
email: m.email ?? '',
|
||||
plz: m.postal_code ?? '',
|
||||
ort: m.city ?? '',
|
||||
status: SL[m.status] ?? m.status,
|
||||
eintritt: m.entry_date ? new Date(m.entry_date).toLocaleDateString('de-DE') : '',
|
||||
});
|
||||
}
|
||||
|
||||
ws.eachRow((row, n) => {
|
||||
if (n > 1 && n % 2 === 0) row.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF1F5F9' } };
|
||||
row.border = { bottom: { style: 'thin', color: { argb: 'FFE2E8F0' } } };
|
||||
});
|
||||
|
||||
ws.addRow({});
|
||||
const sum = ws.addRow({ nr: `Gesamt: ${members.length} Mitglieder` });
|
||||
sum.font = { bold: true };
|
||||
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: Buffer.from(buf as ArrayBuffer).toString('base64'),
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
fileName: `${input.title || 'Mitgliederbericht'}.xlsx`,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft, FileDown } from 'lucide-react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
@@ -12,11 +12,12 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
import { GenerateDocumentForm } from '../_components/generate-document-form';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<{ type?: string }>;
|
||||
@@ -45,7 +46,7 @@ export default async function GenerateDocumentPage({
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const selectedType = type ?? 'member-card';
|
||||
const selectedLabel = DOCUMENT_LABELS[selectedType] ?? 'Dokument';
|
||||
@@ -73,82 +74,16 @@ export default async function GenerateDocumentPage({
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-5">
|
||||
{/* Document Type */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="documentType">Dokumenttyp</Label>
|
||||
<select
|
||||
id="documentType"
|
||||
name="documentType"
|
||||
defaultValue={selectedType}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="member-card">Mitgliedsausweis</option>
|
||||
<option value="invoice">Rechnung</option>
|
||||
<option value="labels">Etiketten</option>
|
||||
<option value="report">Bericht</option>
|
||||
<option value="letter">Brief</option>
|
||||
<option value="certificate">Zertifikat</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="title">Titel / Bezeichnung</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
placeholder={`z.B. ${selectedLabel} für Max Mustermann`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Format */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="format">Format</Label>
|
||||
<select
|
||||
id="format"
|
||||
name="format"
|
||||
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="A4">A4</option>
|
||||
<option value="A5">A5</option>
|
||||
<option value="letter">Letter</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="orientation">Ausrichtung</Label>
|
||||
<select
|
||||
id="orientation"
|
||||
name="orientation"
|
||||
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="portrait">Hochformat</option>
|
||||
<option value="landscape">Querformat</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="rounded-md bg-muted/50 p-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<strong>Hinweis:</strong> Die Dokumentgenerierung verwendet
|
||||
Ihre gespeicherten Vorlagen. Stellen Sie sicher, dass eine
|
||||
passende Vorlage für den gewählten Dokumenttyp existiert.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
<GenerateDocumentForm
|
||||
accountSlug={account}
|
||||
initialType={selectedType}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-between">
|
||||
<CardFooter>
|
||||
<Link href={`/home/${account}/documents`}>
|
||||
<Button variant="outline">Abbrechen</Button>
|
||||
</Link>
|
||||
<Button type="submit">
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
Generieren
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -80,25 +81,16 @@ export default async function DocumentsPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Dokumente">
|
||||
<CmsPageShell account={account} title="Dokumente" description="Dokumente erstellen und verwalten">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Dokumente</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Dokumente erstellen und verwalten
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/home/${account}/documents/templates`}>
|
||||
<Button variant="outline">Vorlagen</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end">
|
||||
<Link href={`/home/${account}/documents/templates`}>
|
||||
<Button variant="outline">Vorlagen verwalten</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Document Type Grid */}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -23,7 +24,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
// Document templates are stored locally for now — placeholder for future DB integration
|
||||
const templates: Array<{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Ticket, Plus } from 'lucide-react';
|
||||
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
@@ -8,6 +9,7 @@ import { createEventManagementApi } from '@kit/event-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -16,6 +18,7 @@ interface PageProps {
|
||||
export default async function HolidayPassesPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
const t = await getTranslations('cms.events');
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
@@ -23,47 +26,47 @@ export default async function HolidayPassesPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createEventManagementApi(client);
|
||||
const passes = await api.listHolidayPasses(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Ferienpässe">
|
||||
<CmsPageShell account={account} title={t('holidayPasses')}>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Ferienpässe</h1>
|
||||
<p className="text-muted-foreground">Ferienpässe und Ferienprogramme verwalten</p>
|
||||
<h1 className="text-2xl font-bold">{t('holidayPasses')}</h1>
|
||||
<p className="text-muted-foreground">{t('holidayPassesDescription')}</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Ferienpass
|
||||
{t('newHolidayPass')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{passes.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Ticket className="h-8 w-8" />}
|
||||
title="Keine Ferienpässe vorhanden"
|
||||
description="Erstellen Sie Ihren ersten Ferienpass."
|
||||
actionLabel="Neuer Ferienpass"
|
||||
title={t('noHolidayPasses')}
|
||||
description={t('noHolidayPassesDescription')}
|
||||
actionLabel={t('newHolidayPass')}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Ferienpässe ({passes.length})</CardTitle>
|
||||
<CardTitle>{t('allHolidayPasses')} ({passes.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">Jahr</th>
|
||||
<th className="p-3 text-right font-medium">Preis</th>
|
||||
<th className="p-3 text-left font-medium">Gültig von</th>
|
||||
<th className="p-3 text-left font-medium">Gültig bis</th>
|
||||
<th className="p-3 text-left font-medium">{t('name')}</th>
|
||||
<th className="p-3 text-left font-medium">{t('year')}</th>
|
||||
<th className="p-3 text-right font-medium">{t('price')}</th>
|
||||
<th className="p-3 text-left font-medium">{t('validFrom')}</th>
|
||||
<th className="p-3 text-left font-medium">{t('validUntil')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { CreateEventForm } from '@kit/event-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
|
||||
export default async function NewEventPage({ params }: Props) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
const t = await getTranslations('cms.events');
|
||||
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neue Veranstaltung" description="Veranstaltung oder Ferienprogramm anlegen">
|
||||
<CmsPageShell account={account} title={t('newEvent')} description={t('newEventDescription')}>
|
||||
<CreateEventForm accountId={acct.id} account={account} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CalendarDays, MapPin, Plus, Users } from 'lucide-react';
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, MapPin, Plus, Users } from 'lucide-react';
|
||||
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
@@ -12,35 +13,19 @@ import { createEventManagementApi } from '@kit/event-management/api';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { EVENT_STATUS_VARIANT, EVENT_STATUS_LABEL } from '~/lib/status-badges';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
const STATUS_BADGE_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
draft: 'secondary',
|
||||
published: 'default',
|
||||
registration_open: 'info',
|
||||
registration_closed: 'outline',
|
||||
cancelled: 'destructive',
|
||||
completed: 'outline',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
published: 'Veröffentlicht',
|
||||
registration_open: 'Anmeldung offen',
|
||||
registration_closed: 'Anmeldung geschlossen',
|
||||
cancelled: 'Abgesagt',
|
||||
completed: 'Abgeschlossen',
|
||||
};
|
||||
|
||||
export default async function EventsPage({ params }: PageProps) {
|
||||
export default async function EventsPage({ params, searchParams }: PageProps) {
|
||||
const { account } = await params;
|
||||
const search = await searchParams;
|
||||
const client = getSupabaseServerClient();
|
||||
const t = await getTranslations('cms.events');
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
@@ -48,27 +33,45 @@ export default async function EventsPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const page = Number(search.page) || 1;
|
||||
const api = createEventManagementApi(client);
|
||||
const events = await api.listEvents(acct.id, { page: 1 });
|
||||
const events = await api.listEvents(acct.id, { page });
|
||||
|
||||
// Fetch registration counts for all events on this page
|
||||
const eventIds = events.data.map((e: Record<string, unknown>) => String(e.id));
|
||||
const registrationCounts = await api.getRegistrationCounts(eventIds);
|
||||
|
||||
// Pre-compute stats before rendering
|
||||
const uniqueLocationCount = new Set(
|
||||
events.data
|
||||
.map((e: Record<string, unknown>) => e.location)
|
||||
.filter(Boolean),
|
||||
).size;
|
||||
|
||||
const totalCapacity = events.data.reduce(
|
||||
(sum: number, e: Record<string, unknown>) =>
|
||||
sum + (Number(e.capacity) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Veranstaltungen">
|
||||
<CmsPageShell account={account} title={t('title')}>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Veranstaltungen</h1>
|
||||
<h1 className="text-2xl font-bold">{t('title')}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Veranstaltungen und Ferienprogramme
|
||||
{t('description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link href={`/home/${account}/events/new`}>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neue Veranstaltung
|
||||
{t('newEvent')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -76,28 +79,18 @@ export default async function EventsPage({ params }: PageProps) {
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<StatsCard
|
||||
title="Veranstaltungen"
|
||||
title={t('title')}
|
||||
value={events.total}
|
||||
icon={<CalendarDays className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Orte"
|
||||
value={
|
||||
new Set(
|
||||
events.data
|
||||
.map((e: Record<string, unknown>) => e.location)
|
||||
.filter(Boolean),
|
||||
).size
|
||||
}
|
||||
title={t('locations')}
|
||||
value={uniqueLocationCount}
|
||||
icon={<MapPin className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Kapazität gesamt"
|
||||
value={events.data.reduce(
|
||||
(sum: number, e: Record<string, unknown>) =>
|
||||
sum + (Number(e.capacity) || 0),
|
||||
0,
|
||||
)}
|
||||
title={t('totalCapacity')}
|
||||
value={totalCapacity}
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
@@ -106,71 +99,105 @@ export default async function EventsPage({ params }: PageProps) {
|
||||
{events.data.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<CalendarDays className="h-8 w-8" />}
|
||||
title="Keine Veranstaltungen vorhanden"
|
||||
description="Erstellen Sie Ihre erste Veranstaltung, um loszulegen."
|
||||
actionLabel="Neue Veranstaltung"
|
||||
title={t('noEvents')}
|
||||
description={t('noEventsDescription')}
|
||||
actionLabel={t('newEvent')}
|
||||
actionHref={`/home/${account}/events/new`}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Veranstaltungen ({events.total})</CardTitle>
|
||||
<CardTitle>{t('allEvents')} ({events.total})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">Datum</th>
|
||||
<th className="p-3 text-left font-medium">Ort</th>
|
||||
<th className="p-3 text-right font-medium">Kapazität</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-right font-medium">Anmeldungen</th>
|
||||
<th className="p-3 text-left font-medium">{t('name')}</th>
|
||||
<th className="p-3 text-left font-medium">{t('eventDate')}</th>
|
||||
<th className="p-3 text-left font-medium">{t('eventLocation')}</th>
|
||||
<th className="p-3 text-right font-medium">{t('capacity')}</th>
|
||||
<th className="p-3 text-left font-medium">{t('status')}</th>
|
||||
<th className="p-3 text-right font-medium">{t('registrations')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.data.map((event: Record<string, unknown>) => (
|
||||
<tr
|
||||
key={String(event.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
<Link
|
||||
href={`/home/${account}/events/${String(event.id)}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{String(event.name)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{event.event_date
|
||||
? new Date(String(event.event_date)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{String(event.location ?? '—')}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{event.capacity != null
|
||||
? String(event.capacity)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
STATUS_BADGE_VARIANT[String(event.status)] ?? 'secondary'
|
||||
}
|
||||
>
|
||||
{STATUS_LABEL[String(event.status)] ?? String(event.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">—</td>
|
||||
</tr>
|
||||
))}
|
||||
{events.data.map((event: Record<string, unknown>) => {
|
||||
const eventId = String(event.id);
|
||||
const regCount = registrationCounts[eventId] ?? 0;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={eventId}
|
||||
className="border-b hover:bg-muted/30"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
<Link
|
||||
href={`/home/${account}/events/${eventId}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{String(event.name)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{event.event_date
|
||||
? new Date(String(event.event_date)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{String(event.location ?? '—')}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{event.capacity != null
|
||||
? String(event.capacity)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
EVENT_STATUS_VARIANT[String(event.status)] ?? 'secondary'
|
||||
}
|
||||
>
|
||||
{EVENT_STATUS_LABEL[String(event.status)] ?? String(event.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right font-medium">
|
||||
{regCount}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{events.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('paginationPage', { page: events.page, totalPages: events.totalPages })}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{events.page > 1 && (
|
||||
<Link href={`/home/${account}/events?page=${events.page - 1}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
{t('paginationPrevious')}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{events.page < events.totalPages && (
|
||||
<Link href={`/home/${account}/events?page=${events.page + 1}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
{t('paginationNext')}
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -2,9 +2,9 @@ import Link from 'next/link';
|
||||
|
||||
import { CalendarDays, ClipboardList, Users } from 'lucide-react';
|
||||
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createEventManagementApi } from '@kit/event-management/api';
|
||||
@@ -12,6 +12,8 @@ import { createEventManagementApi } from '@kit/event-management/api';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { EVENT_STATUS_VARIANT, EVENT_STATUS_LABEL } from '~/lib/status-badges';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -20,6 +22,7 @@ interface PageProps {
|
||||
export default async function EventRegistrationsPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
const t = await getTranslations('cms.events');
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
@@ -27,7 +30,7 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createEventManagementApi(client);
|
||||
const events = await api.listEvents(acct.id, { page: 1 });
|
||||
@@ -56,30 +59,30 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Anmeldungen">
|
||||
<CmsPageShell account={account} title={t('registrations')}>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Anmeldungen</h1>
|
||||
<h1 className="text-2xl font-bold">{t('registrations')}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Anmeldungen aller Veranstaltungen im Überblick
|
||||
{t('registrationsOverview')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<StatsCard
|
||||
title="Veranstaltungen"
|
||||
title={t('title')}
|
||||
value={events.total}
|
||||
icon={<CalendarDays className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Anmeldungen gesamt"
|
||||
title={t('totalRegistrations')}
|
||||
value={totalRegistrations}
|
||||
icon={<ClipboardList className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Mit Anmeldungen"
|
||||
title={t('withRegistrations')}
|
||||
value={eventsWithRegs.length}
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
/>
|
||||
@@ -89,16 +92,16 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
|
||||
{eventsWithRegistrations.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<ClipboardList className="h-8 w-8" />}
|
||||
title="Keine Veranstaltungen vorhanden"
|
||||
description="Erstellen Sie eine Veranstaltung, um Anmeldungen zu erhalten."
|
||||
actionLabel="Neue Veranstaltung"
|
||||
title={t('noEvents')}
|
||||
description={t('noEventsForRegistrations')}
|
||||
actionLabel={t('newEvent')}
|
||||
actionHref={`/home/${account}/events/new`}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Übersicht nach Veranstaltung ({eventsWithRegistrations.length})
|
||||
{t('overviewByEvent')} ({eventsWithRegistrations.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -107,15 +110,15 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">
|
||||
Veranstaltung
|
||||
{t('event')}
|
||||
</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">Kapazität</th>
|
||||
<th className="p-3 text-left font-medium">{t('eventDate')}</th>
|
||||
<th className="p-3 text-left font-medium">{t('status')}</th>
|
||||
<th className="p-3 text-right font-medium">{t('capacity')}</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
Anmeldungen
|
||||
{t('registrations')}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">Auslastung</th>
|
||||
<th className="p-3 text-right font-medium">{t('utilization')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -148,7 +151,13 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="outline">{event.status}</Badge>
|
||||
<Badge
|
||||
variant={
|
||||
EVENT_STATUS_VARIANT[event.status] ?? 'secondary'
|
||||
}
|
||||
>
|
||||
{EVENT_STATUS_LABEL[event.status] ?? event.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{event.capacity ?? '—'}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { createFinanceApi } from '@kit/finance/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string; id: string }>;
|
||||
@@ -49,7 +50,7 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createFinanceApi(client);
|
||||
const invoice = await api.getInvoiceWithItems(id);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { CreateInvoiceForm } from '@kit/finance/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
|
||||
@@ -8,7 +9,7 @@ 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();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neue Rechnung" description="Rechnung mit Positionen erstellen">
|
||||
|
||||
@@ -11,30 +11,16 @@ import { createFinanceApi } from '@kit/finance/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import {
|
||||
INVOICE_STATUS_VARIANT,
|
||||
INVOICE_STATUS_LABEL,
|
||||
} from '~/lib/status-badges';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
draft: 'secondary',
|
||||
sent: 'default',
|
||||
paid: 'info',
|
||||
overdue: 'destructive',
|
||||
cancelled: 'destructive',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
sent: 'Versendet',
|
||||
paid: 'Bezahlt',
|
||||
overdue: 'Überfällig',
|
||||
cancelled: 'Storniert',
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: unknown) =>
|
||||
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
|
||||
Number(amount),
|
||||
@@ -50,7 +36,7 @@ export default async function InvoicesPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createFinanceApi(client);
|
||||
const invoices = await api.listInvoices(acct.id);
|
||||
@@ -141,10 +127,10 @@ export default async function InvoicesPage({ params }: PageProps) {
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
STATUS_VARIANT[status] ?? 'secondary'
|
||||
INVOICE_STATUS_VARIANT[status] ?? 'secondary'
|
||||
}
|
||||
>
|
||||
{STATUS_LABEL[status] ?? status}
|
||||
{INVOICE_STATUS_LABEL[status] ?? status}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Landmark, FileText, Euro, ArrowRight } from 'lucide-react';
|
||||
import { Landmark, FileText, Euro, ArrowRight, Plus } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
@@ -12,49 +12,18 @@ import { createFinanceApi } from '@kit/finance/api';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import {
|
||||
BATCH_STATUS_VARIANT,
|
||||
BATCH_STATUS_LABEL,
|
||||
INVOICE_STATUS_VARIANT,
|
||||
INVOICE_STATUS_LABEL,
|
||||
} from '~/lib/status-badges';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
const BATCH_STATUS_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
draft: 'secondary',
|
||||
ready: 'default',
|
||||
submitted: 'info',
|
||||
completed: 'outline',
|
||||
failed: 'destructive',
|
||||
};
|
||||
|
||||
const BATCH_STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
ready: 'Bereit',
|
||||
submitted: 'Eingereicht',
|
||||
completed: 'Abgeschlossen',
|
||||
failed: 'Fehlgeschlagen',
|
||||
};
|
||||
|
||||
const INVOICE_STATUS_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
draft: 'secondary',
|
||||
sent: 'default',
|
||||
paid: 'info',
|
||||
overdue: 'destructive',
|
||||
cancelled: 'destructive',
|
||||
};
|
||||
|
||||
const INVOICE_STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
sent: 'Versendet',
|
||||
paid: 'Bezahlt',
|
||||
overdue: 'Überfällig',
|
||||
cancelled: 'Storniert',
|
||||
};
|
||||
|
||||
export default async function FinancePage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
@@ -65,7 +34,7 @@ export default async function FinancePage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createFinanceApi(client);
|
||||
|
||||
@@ -89,11 +58,27 @@ export default async function FinancePage({ params }: PageProps) {
|
||||
<CmsPageShell account={account} title="Finanzen">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Finanzen</h1>
|
||||
<p className="text-muted-foreground">
|
||||
SEPA-Einzüge und Rechnungen
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Finanzen</h1>
|
||||
<p className="text-muted-foreground">
|
||||
SEPA-Einzüge und Rechnungen
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/home/${account}/finance/invoices/new`}>
|
||||
<Button variant="outline">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neue Rechnung
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/home/${account}/finance/sepa/new`}>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer SEPA-Einzug
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
@@ -147,7 +132,7 @@ export default async function FinancePage({ params }: PageProps) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batches.slice(0, 5).map((batch: Record<string, unknown>) => (
|
||||
{batches.map((batch: Record<string, unknown>) => (
|
||||
<tr
|
||||
key={String(batch.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
@@ -219,7 +204,7 @@ export default async function FinancePage({ params }: PageProps) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.slice(0, 5).map((invoice: Record<string, unknown>) => (
|
||||
{invoices.map((invoice: Record<string, unknown>) => (
|
||||
<tr
|
||||
key={String(invoice.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createFinanceApi } from '@kit/finance/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -31,7 +32,7 @@ export default async function PaymentsPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createFinanceApi(client);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { createFinanceApi } from '@kit/finance/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string; batchId: string }>;
|
||||
@@ -64,7 +65,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createFinanceApi(client);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { CreateSepaBatchForm } from '@kit/finance/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -17,7 +18,7 @@ export default async function NewSepaBatchPage({ params }: Props) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neuer SEPA-Einzug" description="SEPA-Lastschrifteinzug erstellen">
|
||||
|
||||
@@ -11,30 +11,16 @@ import { createFinanceApi } from '@kit/finance/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import {
|
||||
BATCH_STATUS_VARIANT,
|
||||
BATCH_STATUS_LABEL,
|
||||
} from '~/lib/status-badges';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
draft: 'secondary',
|
||||
ready: 'default',
|
||||
submitted: 'info',
|
||||
completed: 'outline',
|
||||
failed: 'destructive',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
ready: 'Bereit',
|
||||
submitted: 'Eingereicht',
|
||||
completed: 'Abgeschlossen',
|
||||
failed: 'Fehlgeschlagen',
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: unknown) =>
|
||||
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
|
||||
Number(amount),
|
||||
@@ -50,7 +36,7 @@ export default async function SepaPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createFinanceApi(client);
|
||||
const batches = await api.listBatches(acct.id);
|
||||
@@ -115,10 +101,10 @@ export default async function SepaPage({ params }: PageProps) {
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
STATUS_VARIANT[String(batch.status)] ?? 'secondary'
|
||||
BATCH_STATUS_VARIANT[String(batch.status)] ?? 'secondary'
|
||||
}
|
||||
>
|
||||
{STATUS_LABEL[String(batch.status)] ??
|
||||
{BATCH_STATUS_LABEL[String(batch.status)] ??
|
||||
String(batch.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import { FischereiTabNavigation, CatchBooksDataTable } from '@kit/fischerei/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function CatchBooksPage({ 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 <AccountNotFound />;
|
||||
|
||||
const api = createFischereiApi(client);
|
||||
const page = Number(search.page) || 1;
|
||||
const result = await api.listCatchBooks(acct.id, {
|
||||
year: search.year ? Number(search.year) : undefined,
|
||||
status: search.status as string,
|
||||
page,
|
||||
pageSize: 25,
|
||||
});
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Fischerei - Fangbücher">
|
||||
<FischereiTabNavigation account={account} activeTab="catch-books" />
|
||||
<CatchBooksDataTable
|
||||
data={result.data}
|
||||
total={result.total}
|
||||
page={page}
|
||||
pageSize={25}
|
||||
account={account}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import { FischereiTabNavigation, CompetitionsDataTable } from '@kit/fischerei/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function CompetitionsPage({ params, searchParams }: Props) {
|
||||
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 <AccountNotFound />;
|
||||
|
||||
const api = createFischereiApi(client);
|
||||
const page = Number(search.page) || 1;
|
||||
const result = await api.listCompetitions(acct.id, {
|
||||
page,
|
||||
pageSize: 25,
|
||||
});
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Fischerei - Wettbewerbe">
|
||||
<FischereiTabNavigation account={account} activeTab="competitions" />
|
||||
<CompetitionsDataTable
|
||||
data={result.data}
|
||||
total={result.total}
|
||||
page={page}
|
||||
pageSize={25}
|
||||
account={account}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export default function FischereiLayout({ children }: { children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
119
apps/web/app/[locale]/home/[account]/fischerei/leases/page.tsx
Normal file
119
apps/web/app/[locale]/home/[account]/fischerei/leases/page.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import { FischereiTabNavigation } from '@kit/fischerei/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { LEASE_PAYMENT_LABELS } from '@kit/fischerei/lib/fischerei-constants';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function LeasesPage({ 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 <AccountNotFound />;
|
||||
|
||||
const api = createFischereiApi(client);
|
||||
const page = Number(search.page) || 1;
|
||||
const result = await api.listLeases(acct.id, {
|
||||
page,
|
||||
pageSize: 25,
|
||||
});
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Fischerei - Pachten">
|
||||
<FischereiTabNavigation account={account} activeTab="leases" />
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Pachten</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gewässerpachtverträge verwalten
|
||||
</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pachten ({result.total})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{result.data.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
|
||||
<h3 className="text-lg font-semibold">
|
||||
Keine Pachten vorhanden
|
||||
</h3>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
Erstellen Sie Ihren ersten Pachtvertrag.
|
||||
</p>
|
||||
</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 font-medium">Verpächter</th>
|
||||
<th className="p-3 text-left font-medium">Gewässer</th>
|
||||
<th className="p-3 text-left font-medium">Beginn</th>
|
||||
<th className="p-3 text-left font-medium">Ende</th>
|
||||
<th className="p-3 text-right font-medium">Jahresbetrag (€)</th>
|
||||
<th className="p-3 text-left font-medium">Zahlungsart</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.data.map((lease: Record<string, unknown>) => {
|
||||
const waters = lease.waters as Record<string, unknown> | null;
|
||||
const paymentMethod = String(lease.payment_method ?? 'ueberweisung');
|
||||
|
||||
return (
|
||||
<tr key={String(lease.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">
|
||||
{String(lease.lessor_name)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{waters ? String(waters.name) : '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{lease.start_date
|
||||
? new Date(String(lease.start_date)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{lease.end_date
|
||||
? new Date(String(lease.end_date)).toLocaleDateString('de-DE')
|
||||
: 'unbefristet'}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{lease.initial_amount != null
|
||||
? `${Number(lease.initial_amount).toLocaleString('de-DE', { minimumFractionDigits: 2 })} €`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="outline">
|
||||
{LEASE_PAYMENT_LABELS[paymentMethod] ?? paymentMethod}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
33
apps/web/app/[locale]/home/[account]/fischerei/page.tsx
Normal file
33
apps/web/app/[locale]/home/[account]/fischerei/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import { FischereiTabNavigation, FischereiDashboard } from '@kit/fischerei/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function FischereiPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createFischereiApi(client);
|
||||
const stats = await api.getDashboardStats(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Fischerei">
|
||||
<FischereiTabNavigation account={account} activeTab="overview" />
|
||||
<FischereiDashboard stats={stats} account={account} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import { FischereiTabNavigation } from '@kit/fischerei/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function PermitsPage({ 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 <AccountNotFound />;
|
||||
|
||||
const api = createFischereiApi(client);
|
||||
const permits = await api.listPermits(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Fischerei - Erlaubnisscheine">
|
||||
<FischereiTabNavigation account={account} activeTab="permits" />
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Erlaubnisscheine</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Erlaubnisscheine und Gewässerkarten verwalten
|
||||
</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Erlaubnisscheine ({permits.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{permits.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
|
||||
<h3 className="text-lg font-semibold">
|
||||
Keine Erlaubnisscheine vorhanden
|
||||
</h3>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
Erstellen Sie Ihren ersten Erlaubnisschein.
|
||||
</p>
|
||||
</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 font-medium">Bezeichnung</th>
|
||||
<th className="p-3 text-left font-medium">Kurzcode</th>
|
||||
<th className="p-3 text-left font-medium">Hauptgewässer</th>
|
||||
<th className="p-3 text-right font-medium">Gesamtmenge</th>
|
||||
<th className="p-3 text-center font-medium">Zum Verkauf</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{permits.map((permit: Record<string, unknown>) => {
|
||||
const waters = permit.waters as Record<string, unknown> | null;
|
||||
|
||||
return (
|
||||
<tr key={String(permit.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">{String(permit.name)}</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{String(permit.short_code ?? '—')}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{waters ? String(waters.name) : '—'}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{permit.total_quantity != null
|
||||
? String(permit.total_quantity)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
{permit.is_for_sale ? '✓' : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { FischereiTabNavigation, CreateSpeciesForm } from '@kit/fischerei/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewSpeciesPage({ 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 <AccountNotFound />;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neue Fischart">
|
||||
<FischereiTabNavigation account={account} activeTab="species" />
|
||||
<CreateSpeciesForm accountId={acct.id} account={account} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import { FischereiTabNavigation, SpeciesDataTable } from '@kit/fischerei/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function SpeciesPage({ 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 <AccountNotFound />;
|
||||
|
||||
const api = createFischereiApi(client);
|
||||
const page = Number(search.page) || 1;
|
||||
const result = await api.listSpecies(acct.id, {
|
||||
search: search.q as string,
|
||||
page,
|
||||
pageSize: 50,
|
||||
});
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Fischerei - Fischarten">
|
||||
<FischereiTabNavigation account={account} activeTab="species" />
|
||||
<SpeciesDataTable
|
||||
data={result.data}
|
||||
total={result.total}
|
||||
page={page}
|
||||
pageSize={50}
|
||||
account={account}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { FischereiTabNavigation } from '@kit/fischerei/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function StatisticsPage({ 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 <AccountNotFound />;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Fischerei - Statistiken">
|
||||
<FischereiTabNavigation account={account} activeTab="statistics" />
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Statistiken</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Fangstatistiken und Auswertungen
|
||||
</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fangstatistiken</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
|
||||
<h3 className="text-lg font-semibold">Noch keine Daten vorhanden</h3>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
Sobald Fangbücher eingereicht und geprüft werden, erscheinen hier Statistiken und Auswertungen.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import { FischereiTabNavigation, CreateStockingForm } from '@kit/fischerei/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewStockingPage({ 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 <AccountNotFound />;
|
||||
|
||||
const api = createFischereiApi(client);
|
||||
|
||||
// Load waters and species lists for form dropdowns
|
||||
const [watersResult, speciesResult] = await Promise.all([
|
||||
api.listWaters(acct.id, { pageSize: 200 }),
|
||||
api.listSpecies(acct.id, { pageSize: 200 }),
|
||||
]);
|
||||
|
||||
const waters = watersResult.data.map((w: Record<string, unknown>) => ({
|
||||
id: String(w.id),
|
||||
name: String(w.name),
|
||||
}));
|
||||
|
||||
const species = speciesResult.data.map((s: Record<string, unknown>) => ({
|
||||
id: String(s.id),
|
||||
name: String(s.name),
|
||||
}));
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Besatz eintragen">
|
||||
<FischereiTabNavigation account={account} activeTab="stocking" />
|
||||
<CreateStockingForm
|
||||
accountId={acct.id}
|
||||
account={account}
|
||||
waters={waters}
|
||||
species={species}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import { FischereiTabNavigation, StockingDataTable } from '@kit/fischerei/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function StockingPage({ 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 <AccountNotFound />;
|
||||
|
||||
const api = createFischereiApi(client);
|
||||
const page = Number(search.page) || 1;
|
||||
const result = await api.listStocking(acct.id, {
|
||||
page,
|
||||
pageSize: 25,
|
||||
});
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Fischerei - Besatz">
|
||||
<FischereiTabNavigation account={account} activeTab="stocking" />
|
||||
<StockingDataTable
|
||||
data={result.data}
|
||||
total={result.total}
|
||||
page={page}
|
||||
pageSize={25}
|
||||
account={account}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { FischereiTabNavigation, CreateWaterForm } from '@kit/fischerei/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewWaterPage({ 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 <AccountNotFound />;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neues Gewässer">
|
||||
<FischereiTabNavigation account={account} activeTab="waters" />
|
||||
<CreateWaterForm accountId={acct.id} account={account} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import { FischereiTabNavigation, WatersDataTable } from '@kit/fischerei/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function WatersPage({ 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 <AccountNotFound />;
|
||||
|
||||
const api = createFischereiApi(client);
|
||||
const page = Number(search.page) || 1;
|
||||
const result = await api.listWaters(acct.id, {
|
||||
search: search.q as string,
|
||||
waterType: search.type as string,
|
||||
page,
|
||||
pageSize: 25,
|
||||
});
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Fischerei - Gewässer">
|
||||
<FischereiTabNavigation account={account} activeTab="waters" />
|
||||
<WatersDataTable
|
||||
data={result.data}
|
||||
total={result.total}
|
||||
page={page}
|
||||
pageSize={25}
|
||||
account={account}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { cache } from 'react';
|
||||
import { use } from 'react';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { Fish, FileSignature, Building2 } from 'lucide-react';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { TeamAccountWorkspaceContextProvider } from '@kit/team-accounts/components';
|
||||
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
|
||||
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
|
||||
import { SidebarProvider } from '@kit/ui/sidebar';
|
||||
|
||||
@@ -33,21 +37,109 @@ function TeamWorkspaceLayout({ children, params }: TeamWorkspaceLayoutProps) {
|
||||
return <HeaderLayout account={account}>{children}</HeaderLayout>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query account_settings.features for a given account slug.
|
||||
* Cached per-request so multiple calls don't hit the DB twice.
|
||||
*/
|
||||
const getAccountFeatures = cache(async (accountSlug: string) => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: accountData } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', accountSlug)
|
||||
.single();
|
||||
|
||||
if (!accountData) return {};
|
||||
|
||||
const { data: settings } = await client
|
||||
.from('account_settings')
|
||||
.select('features')
|
||||
.eq('account_id', accountData.id)
|
||||
.maybeSingle();
|
||||
|
||||
return (settings?.features as Record<string, boolean>) ?? {};
|
||||
});
|
||||
|
||||
/**
|
||||
* Inject per-account feature routes (e.g. Fischerei) into the parsed
|
||||
* navigation config. The entry is inserted right after "Veranstaltungen".
|
||||
*/
|
||||
function injectAccountFeatureRoutes(
|
||||
config: z.output<typeof NavigationConfigSchema>,
|
||||
account: string,
|
||||
features: Record<string, boolean>,
|
||||
): z.output<typeof NavigationConfigSchema> {
|
||||
if (!features.fischerei && !features.meetings && !features.verband) return config;
|
||||
|
||||
const featureEntries: Array<{
|
||||
label: string;
|
||||
path: string;
|
||||
Icon: React.ReactNode;
|
||||
}> = [];
|
||||
|
||||
if (features.fischerei) {
|
||||
featureEntries.push({
|
||||
label: 'common.routes.fischerei',
|
||||
path: `/home/${account}/fischerei`,
|
||||
Icon: <Fish className="w-4" />,
|
||||
});
|
||||
}
|
||||
|
||||
if (features.meetings) {
|
||||
featureEntries.push({
|
||||
label: 'common.routes.meetings',
|
||||
path: `/home/${account}/meetings`,
|
||||
Icon: <FileSignature className="w-4" />,
|
||||
});
|
||||
}
|
||||
|
||||
if (features.verband) {
|
||||
featureEntries.push({
|
||||
label: 'common.routes.verband',
|
||||
path: `/home/${account}/verband`,
|
||||
Icon: <Building2 className="w-4" />,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
routes: config.routes.map((group) => {
|
||||
if (!('children' in group)) return group;
|
||||
|
||||
const eventsIndex = group.children.findIndex(
|
||||
(child) => child.label === 'common.routes.events',
|
||||
);
|
||||
|
||||
if (eventsIndex === -1) return group;
|
||||
|
||||
const newChildren = [...group.children];
|
||||
newChildren.splice(eventsIndex + 1, 0, ...featureEntries);
|
||||
|
||||
return { ...group, children: newChildren };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function SidebarLayout({
|
||||
account,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
account: string;
|
||||
}>) {
|
||||
const [data, state] = await Promise.all([
|
||||
const [data, state, features] = await Promise.all([
|
||||
loadTeamWorkspace(account),
|
||||
getLayoutState(account),
|
||||
getAccountFeatures(account),
|
||||
]);
|
||||
|
||||
if (!data) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
const baseConfig = getTeamAccountSidebarConfig(account);
|
||||
const config = injectAccountFeatureRoutes(baseConfig, account, features);
|
||||
|
||||
const accounts = data.accounts.map(({ name, slug, picture_url }) => ({
|
||||
label: name,
|
||||
value: slug,
|
||||
@@ -64,6 +156,7 @@ async function SidebarLayout({
|
||||
accountId={data.account.id}
|
||||
accounts={accounts}
|
||||
user={data.user}
|
||||
config={config}
|
||||
/>
|
||||
</PageNavigation>
|
||||
|
||||
@@ -75,6 +168,7 @@ async function SidebarLayout({
|
||||
userId={data.user.id}
|
||||
accounts={accounts}
|
||||
account={account}
|
||||
config={config}
|
||||
/>
|
||||
</div>
|
||||
</PageMobileNavigation>
|
||||
@@ -86,19 +180,25 @@ async function SidebarLayout({
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderLayout({
|
||||
async function HeaderLayout({
|
||||
account,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
account: string;
|
||||
}>) {
|
||||
const data = use(loadTeamWorkspace(account));
|
||||
const [data, features] = await Promise.all([
|
||||
loadTeamWorkspace(account),
|
||||
getAccountFeatures(account),
|
||||
]);
|
||||
|
||||
const baseConfig = getTeamAccountSidebarConfig(account);
|
||||
const config = injectAccountFeatureRoutes(baseConfig, account, features);
|
||||
|
||||
return (
|
||||
<TeamAccountWorkspaceContextProvider value={data}>
|
||||
<Page style={'header'}>
|
||||
<PageNavigation>
|
||||
<TeamAccountNavigationMenu workspace={data} />
|
||||
<TeamAccountNavigationMenu workspace={data} config={config} />
|
||||
</PageNavigation>
|
||||
|
||||
{children}
|
||||
|
||||
5
apps/web/app/[locale]/home/[account]/meetings/layout.tsx
Normal file
5
apps/web/app/[locale]/home/[account]/meetings/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export default function MeetingsLayout({ children }: { children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
43
apps/web/app/[locale]/home/[account]/meetings/page.tsx
Normal file
43
apps/web/app/[locale]/home/[account]/meetings/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMeetingsApi } from '@kit/sitzungsprotokolle/api';
|
||||
import { MeetingsTabNavigation, MeetingsDashboard } from '@kit/sitzungsprotokolle/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function MeetingsPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createMeetingsApi(client);
|
||||
|
||||
const [stats, recentProtocols, overdueTasks] = await Promise.all([
|
||||
api.getDashboardStats(acct.id),
|
||||
api.getRecentProtocols(acct.id),
|
||||
api.getOverdueTasks(acct.id),
|
||||
]);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Sitzungsprotokolle">
|
||||
<MeetingsTabNavigation account={account} activeTab="overview" />
|
||||
<MeetingsDashboard
|
||||
stats={stats}
|
||||
recentProtocols={recentProtocols}
|
||||
overdueTasks={overdueTasks}
|
||||
account={account}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMeetingsApi } from '@kit/sitzungsprotokolle/api';
|
||||
import { MeetingsTabNavigation, ProtocolItemsList } from '@kit/sitzungsprotokolle/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
import { MEETING_TYPE_LABELS } from '@kit/sitzungsprotokolle/lib/meetings-constants';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string; protocolId: string }>;
|
||||
}
|
||||
|
||||
export default async function ProtocolDetailPage({ params }: PageProps) {
|
||||
const { account, protocolId } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createMeetingsApi(client);
|
||||
|
||||
let protocol;
|
||||
try {
|
||||
protocol = await api.getProtocol(protocolId);
|
||||
} catch {
|
||||
return (
|
||||
<CmsPageShell account={account} title="Sitzungsprotokolle">
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-lg font-semibold">Protokoll nicht gefunden</h2>
|
||||
<Link href={`/home/${account}/meetings/protocols`} className="mt-4 inline-block">
|
||||
<Button variant="outline">Zurück zur Übersicht</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const items = await api.listItems(protocolId);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Sitzungsprotokolle">
|
||||
<MeetingsTabNavigation account={account} activeTab="protocols" />
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Back + Title */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/home/${account}/meetings/protocols`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Zurück
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Protocol Header */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">{protocol.title}</CardTitle>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{new Date(protocol.meeting_date).toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<Badge variant="secondary">
|
||||
{MEETING_TYPE_LABELS[protocol.meeting_type] ?? protocol.meeting_type}
|
||||
</Badge>
|
||||
{protocol.is_published ? (
|
||||
<Badge variant="default">Veröffentlicht</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Entwurf</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{protocol.location && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Ort</p>
|
||||
<p className="text-sm">{protocol.location}</p>
|
||||
</div>
|
||||
)}
|
||||
{protocol.attendees && (
|
||||
<div className="sm:col-span-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Teilnehmer</p>
|
||||
<p className="text-sm whitespace-pre-line">{protocol.attendees}</p>
|
||||
</div>
|
||||
)}
|
||||
{protocol.remarks && (
|
||||
<div className="sm:col-span-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Anmerkungen</p>
|
||||
<p className="text-sm whitespace-pre-line">{protocol.remarks}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Items List */}
|
||||
<ProtocolItemsList
|
||||
items={items}
|
||||
protocolId={protocolId}
|
||||
account={account}
|
||||
/>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { MeetingsTabNavigation, CreateProtocolForm } from '@kit/sitzungsprotokolle/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewProtocolPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Sitzungsprotokolle">
|
||||
<MeetingsTabNavigation account={account} activeTab="protocols" />
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Neues Protokoll erstellen</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Erstellen Sie ein neues Sitzungsprotokoll mit Tagesordnungspunkten.
|
||||
</p>
|
||||
</div>
|
||||
<CreateProtocolForm accountId={acct.id} account={account} />
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMeetingsApi } from '@kit/sitzungsprotokolle/api';
|
||||
import { MeetingsTabNavigation, ProtocolsDataTable } from '@kit/sitzungsprotokolle/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function ProtocolsPage({ params, searchParams }: PageProps) {
|
||||
const { account } = await params;
|
||||
const sp = await searchParams;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createMeetingsApi(client);
|
||||
|
||||
const search = typeof sp.q === 'string' ? sp.q : undefined;
|
||||
const meetingType = typeof sp.type === 'string' ? sp.type : undefined;
|
||||
const page = typeof sp.page === 'string' ? parseInt(sp.page, 10) : 1;
|
||||
|
||||
const result = await api.listProtocols(acct.id, {
|
||||
search,
|
||||
meetingType,
|
||||
page,
|
||||
});
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Sitzungsprotokolle">
|
||||
<MeetingsTabNavigation account={account} activeTab="protocols" />
|
||||
<ProtocolsDataTable
|
||||
data={result.data}
|
||||
total={result.total}
|
||||
page={result.page}
|
||||
pageSize={result.pageSize}
|
||||
account={account}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
52
apps/web/app/[locale]/home/[account]/meetings/tasks/page.tsx
Normal file
52
apps/web/app/[locale]/home/[account]/meetings/tasks/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMeetingsApi } from '@kit/sitzungsprotokolle/api';
|
||||
import { MeetingsTabNavigation, OpenTasksView } from '@kit/sitzungsprotokolle/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function TasksPage({ params, searchParams }: PageProps) {
|
||||
const { account } = await params;
|
||||
const sp = await searchParams;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createMeetingsApi(client);
|
||||
|
||||
const page = typeof sp.page === 'string' ? parseInt(sp.page, 10) : 1;
|
||||
|
||||
const result = await api.listOpenTasks(acct.id, { page });
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Sitzungsprotokolle">
|
||||
<MeetingsTabNavigation account={account} activeTab="tasks" />
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Offene Aufgaben</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Alle offenen und in Bearbeitung befindlichen Tagesordnungspunkte über alle Protokolle.
|
||||
</p>
|
||||
</div>
|
||||
<OpenTasksView
|
||||
data={result.data as any}
|
||||
total={result.total}
|
||||
page={result.page}
|
||||
pageSize={result.pageSize}
|
||||
account={account}
|
||||
/>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { EditMemberForm } from '@kit/member-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string; memberId: string }>;
|
||||
@@ -11,7 +12,7 @@ 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>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const member = await api.getMember(memberId);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { MemberDetailView } from '@kit/member-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string; memberId: string }>;
|
||||
@@ -11,7 +12,7 @@ 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>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const member = await api.getMember(memberId);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { ApplicationWorkflow } from '@kit/member-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -11,7 +12,7 @@ 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();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const applications = await api.listApplications(acct.id);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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 { CreditCard } from 'lucide-react';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
/** All active members are fetched for the card overview. */
|
||||
const CARDS_PAGE_SIZE = 100;
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -15,67 +16,31 @@ 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>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const result = await api.listMembers(acct.id, { status: 'active', pageSize: 100 });
|
||||
const result = await api.listMembers(acct.id, { status: 'active', pageSize: CARDS_PAGE_SIZE });
|
||||
const members = result.data;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Mitgliedsausweise" description="Ausweise erstellen und verwalten">
|
||||
<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>
|
||||
{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`}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<CreditCard className="h-8 w-8" />}
|
||||
title="Feature in Entwicklung"
|
||||
description={`Die Ausweiserstellung für ${members.length} aktive Mitglieder wird derzeit entwickelt. Diese Funktion wird in einem kommenden Update verfügbar sein.`}
|
||||
actionLabel="Mitglieder verwalten"
|
||||
actionHref={`/home/${account}/members-cms`}
|
||||
/>
|
||||
)}
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { createDepartment } from '@kit/member-management/actions/member-actions';
|
||||
|
||||
interface CreateDepartmentDialogProps {
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
export function CreateDepartmentDialog({ accountId }: CreateDepartmentDialogProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const { execute, isPending } = useAction(createDepartment, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Abteilung erstellt');
|
||||
setOpen(false);
|
||||
setName('');
|
||||
setDescription('');
|
||||
router.refresh();
|
||||
}
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Erstellen der Abteilung');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
execute({ accountId, name: name.trim(), description: description.trim() || undefined });
|
||||
},
|
||||
[execute, accountId, name, description],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="sm" />}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Neue Abteilung
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neue Abteilung</DialogTitle>
|
||||
<DialogDescription>
|
||||
Erstellen Sie eine neue Abteilung oder Sparte für Ihren Verein.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dept-name">Name</Label>
|
||||
<Input
|
||||
id="dept-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="z. B. Jugendabteilung"
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={128}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dept-description">Beschreibung (optional)</Label>
|
||||
<Input
|
||||
id="dept-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Kurze Beschreibung"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="submit" disabled={isPending || !name.trim()}>
|
||||
{isPending ? 'Wird erstellt…' : 'Erstellen'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
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';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
import { CreateDepartmentDialog } from './create-department-dialog';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -15,40 +15,45 @@ 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>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const departments = await api.listDepartments(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Abteilungen" description="Sparten und Abteilungen verwalten">
|
||||
{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 className="space-y-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<CreateDepartmentDialog accountId={acct.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{departments.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Users className="h-8 w-8" />}
|
||||
title="Keine Abteilungen vorhanden"
|
||||
description="Erstellen Sie Ihre erste 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>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { DuesCategoryManager } from '@kit/member-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -11,7 +12,7 @@ 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();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const categories = await api.listDuesCategories(acct.id);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { MemberImportWizard } from '@kit/member-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -10,7 +11,7 @@ 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>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Mitglieder importieren" description="CSV-Datei importieren">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { CreateMemberForm } from '@kit/member-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
|
||||
@@ -9,7 +10,7 @@ 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>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const duesCategories = await api.listDuesCategories(acct.id);
|
||||
|
||||
@@ -2,6 +2,9 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { MembersDataTable } from '@kit/member-management/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -13,7 +16,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
|
||||
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>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const page = Number(search.page) || 1;
|
||||
@@ -21,7 +24,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
|
||||
search: search.q as string,
|
||||
status: search.status as string,
|
||||
page,
|
||||
pageSize: 25,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
const duesCategories = await api.listDuesCategories(acct.id);
|
||||
|
||||
@@ -31,7 +34,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
|
||||
data={result.data}
|
||||
total={result.total}
|
||||
page={page}
|
||||
pageSize={25}
|
||||
pageSize={PAGE_SIZE}
|
||||
account={account}
|
||||
duesCategories={(duesCategories ?? []).map((c: Record<string, unknown>) => ({
|
||||
id: String(c.id), name: String(c.name),
|
||||
|
||||
@@ -8,6 +8,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';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -23,7 +24,7 @@ export default async function MemberStatisticsPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const stats = await api.getMemberStatistics(acct.id);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
import { Fish, FileSignature, Building2 } from 'lucide-react';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { Switch } from '@kit/ui/switch';
|
||||
|
||||
import { toggleModuleAction } from '../_lib/server/toggle-module';
|
||||
|
||||
interface ModuleDefinition {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const AVAILABLE_MODULES: ModuleDefinition[] = [
|
||||
{
|
||||
key: 'fischerei',
|
||||
label: 'Fischerei',
|
||||
description:
|
||||
'Gewässer, Fischarten, Besatz, Fangbücher und Wettbewerbe verwalten',
|
||||
icon: <Fish className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
key: 'meetings',
|
||||
label: 'Sitzungsprotokolle',
|
||||
description:
|
||||
'Sitzungsprotokolle, Tagesordnungspunkte und Beschlüsse verwalten',
|
||||
icon: <FileSignature className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
key: 'verband',
|
||||
label: 'Verbandsverwaltung',
|
||||
description:
|
||||
'Mitgliedsvereine, Kontaktpersonen, Beiträge und Statistiken verwalten',
|
||||
icon: <Building2 className="h-5 w-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
interface ModuleTogglesProps {
|
||||
accountId: string;
|
||||
features: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export function ModuleToggles({ accountId, features }: ModuleTogglesProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleToggle = (moduleKey: string, enabled: boolean) => {
|
||||
startTransition(async () => {
|
||||
const result = await toggleModuleAction(accountId, moduleKey, enabled);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
enabled ? 'Modul aktiviert' : 'Modul deaktiviert',
|
||||
);
|
||||
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error('Fehler beim Aktualisieren des Moduls');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Verfügbare Module</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Aktivieren oder deaktivieren Sie Module für Ihren Verein
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y rounded-lg border">
|
||||
{AVAILABLE_MODULES.map((mod) => {
|
||||
const isEnabled = features[mod.key] === true;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={mod.key}
|
||||
className="flex items-center justify-between gap-4 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-muted-foreground">{mod.icon}</div>
|
||||
<div>
|
||||
<p className="font-medium">{mod.label}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{mod.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggle(mod.key, Boolean(checked))
|
||||
}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export async function toggleModuleAction(
|
||||
accountId: string,
|
||||
moduleKey: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
// Read current features
|
||||
const { data: settings } = await client
|
||||
.from('account_settings')
|
||||
.select('features')
|
||||
.eq('account_id', accountId)
|
||||
.maybeSingle();
|
||||
|
||||
const currentFeatures =
|
||||
(settings?.features as Record<string, boolean>) ?? {};
|
||||
const newFeatures = { ...currentFeatures, [moduleKey]: enabled };
|
||||
|
||||
// Upsert
|
||||
const { error } = await client.from('account_settings').upsert(
|
||||
{
|
||||
account_id: accountId,
|
||||
features: newFeatures,
|
||||
},
|
||||
{ onConflict: 'account_id' },
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
revalidatePath(`/home`, 'layout');
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
import { ModuleToggles } from './_components/module-toggles';
|
||||
|
||||
interface ModulesPageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
@@ -19,48 +26,59 @@ export default async function ModulesPage({ params }: ModulesPageProps) {
|
||||
.single();
|
||||
|
||||
if (!accountData) {
|
||||
return <div>Account not found</div>;
|
||||
return <AccountNotFound />;
|
||||
}
|
||||
|
||||
// Load account features
|
||||
const { data: settings } = await client
|
||||
.from('account_settings')
|
||||
.select('features')
|
||||
.eq('account_id', accountData.id)
|
||||
.maybeSingle();
|
||||
|
||||
const features = (settings?.features as Record<string, boolean>) ?? {};
|
||||
|
||||
const modules = await api.modules.listModules(accountData.id);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Module</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Verwalten Sie Ihre Datenmodule
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<CmsPageShell
|
||||
account={account}
|
||||
title="Module"
|
||||
description="Verwalten Sie Ihre Datenmodule"
|
||||
>
|
||||
<div className="flex flex-col gap-8">
|
||||
<ModuleToggles accountId={accountData.id} features={features} />
|
||||
|
||||
{modules.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Noch keine Module vorhanden. Erstellen Sie Ihr erstes Modul.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{modules.map((module: Record<string, unknown>) => (
|
||||
<div
|
||||
key={module.id as string}
|
||||
className="rounded-lg border p-4 hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<h3 className="font-semibold">{String(module.display_name)}</h3>
|
||||
{module.description ? (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{String(module.description)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Status: {String(module.status)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{modules.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Noch keine Module vorhanden. Erstellen Sie Ihr erstes Modul.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{modules.map((module: Record<string, unknown>) => (
|
||||
<Link
|
||||
key={module.id as string}
|
||||
href={`/home/${account}/modules/${module.id as string}`}
|
||||
className="block rounded-lg border p-4 hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<h3 className="font-semibold">
|
||||
{String(module.display_name)}
|
||||
</h3>
|
||||
{module.description ? (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{String(module.description)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Status: {String(module.status)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,47 +11,18 @@ import { createNewsletterApi } from '@kit/newsletter/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import {
|
||||
NEWSLETTER_STATUS_VARIANT,
|
||||
NEWSLETTER_STATUS_LABEL,
|
||||
NEWSLETTER_RECIPIENT_STATUS_VARIANT,
|
||||
NEWSLETTER_RECIPIENT_STATUS_LABEL,
|
||||
} from '~/lib/status-badges';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string; campaignId: string }>;
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
draft: 'secondary',
|
||||
scheduled: 'default',
|
||||
sending: 'info',
|
||||
sent: 'outline',
|
||||
failed: 'destructive',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
scheduled: 'Geplant',
|
||||
sending: 'Wird gesendet',
|
||||
sent: 'Gesendet',
|
||||
failed: 'Fehlgeschlagen',
|
||||
};
|
||||
|
||||
const RECIPIENT_STATUS_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
pending: 'secondary',
|
||||
sent: 'default',
|
||||
failed: 'destructive',
|
||||
bounced: 'destructive',
|
||||
};
|
||||
|
||||
const RECIPIENT_STATUS_LABEL: Record<string, string> = {
|
||||
pending: 'Ausstehend',
|
||||
sent: 'Gesendet',
|
||||
failed: 'Fehlgeschlagen',
|
||||
bounced: 'Zurückgewiesen',
|
||||
};
|
||||
|
||||
export default async function NewsletterDetailPage({ params }: PageProps) {
|
||||
const { account, campaignId } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
@@ -62,7 +33,7 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createNewsletterApi(client);
|
||||
|
||||
@@ -102,8 +73,8 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
|
||||
<CardTitle>
|
||||
{String(newsletter.subject ?? '(Kein Betreff)')}
|
||||
</CardTitle>
|
||||
<Badge variant={STATUS_VARIANT[status] ?? 'secondary'}>
|
||||
{STATUS_LABEL[status] ?? status}
|
||||
<Badge variant={NEWSLETTER_STATUS_VARIANT[status] ?? 'secondary'}>
|
||||
{NEWSLETTER_STATUS_LABEL[status] ?? status}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -175,10 +146,10 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
RECIPIENT_STATUS_VARIANT[rStatus] ?? 'secondary'
|
||||
NEWSLETTER_RECIPIENT_STATUS_VARIANT[rStatus] ?? 'secondary'
|
||||
}
|
||||
>
|
||||
{RECIPIENT_STATUS_LABEL[rStatus] ?? rStatus}
|
||||
{NEWSLETTER_RECIPIENT_STATUS_LABEL[rStatus] ?? rStatus}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { CreateNewsletterForm } from '@kit/newsletter/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
|
||||
@@ -8,7 +9,7 @@ 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();
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neuer Newsletter" description="Newsletter-Kampagne erstellen">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Mail, Plus, Send, Users } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, Mail, Plus, Send, Users } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
@@ -12,32 +12,22 @@ import { createNewsletterApi } from '@kit/newsletter/api';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import {
|
||||
NEWSLETTER_STATUS_VARIANT,
|
||||
NEWSLETTER_STATUS_LABEL,
|
||||
} from '~/lib/status-badges';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
const STATUS_BADGE_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
draft: 'secondary',
|
||||
scheduled: 'default',
|
||||
sending: 'info',
|
||||
sent: 'outline',
|
||||
failed: 'destructive',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
scheduled: 'Geplant',
|
||||
sending: 'Wird gesendet',
|
||||
sent: 'Gesendet',
|
||||
failed: 'Fehlgeschlagen',
|
||||
};
|
||||
|
||||
export default async function NewsletterPage({ params }: PageProps) {
|
||||
export default async function NewsletterPage({ params, searchParams }: PageProps) {
|
||||
const { account } = await params;
|
||||
const search = await searchParams;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
@@ -46,21 +36,29 @@ export default async function NewsletterPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createNewsletterApi(client);
|
||||
const newsletters = await api.listNewsletters(acct.id);
|
||||
const allNewsletters = await api.listNewsletters(acct.id);
|
||||
|
||||
const sentCount = newsletters.filter(
|
||||
const sentCount = allNewsletters.filter(
|
||||
(n: Record<string, unknown>) => n.status === 'sent',
|
||||
).length;
|
||||
|
||||
const totalRecipients = newsletters.reduce(
|
||||
const totalRecipients = allNewsletters.reduce(
|
||||
(sum: number, n: Record<string, unknown>) =>
|
||||
sum + (Number(n.total_recipients) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
// Pagination
|
||||
const currentPage = Math.max(1, Number(search.page) || 1);
|
||||
const totalItems = allNewsletters.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE));
|
||||
const safePage = Math.min(currentPage, totalPages);
|
||||
const startIdx = (safePage - 1) * PAGE_SIZE;
|
||||
const newsletters = allNewsletters.slice(startIdx, startIdx + PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Newsletter">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
@@ -85,7 +83,7 @@ export default async function NewsletterPage({ params }: PageProps) {
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<StatsCard
|
||||
title="Newsletter"
|
||||
value={newsletters.length}
|
||||
value={totalItems}
|
||||
icon={<Mail className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
@@ -101,7 +99,7 @@ export default async function NewsletterPage({ params }: PageProps) {
|
||||
</div>
|
||||
|
||||
{/* Table or Empty State */}
|
||||
{newsletters.length === 0 ? (
|
||||
{totalItems === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Mail className="h-8 w-8" />}
|
||||
title="Keine Newsletter vorhanden"
|
||||
@@ -112,7 +110,7 @@ export default async function NewsletterPage({ params }: PageProps) {
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Newsletter ({newsletters.length})</CardTitle>
|
||||
<CardTitle>Alle Newsletter ({totalItems})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
@@ -143,10 +141,10 @@ export default async function NewsletterPage({ params }: PageProps) {
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
STATUS_BADGE_VARIANT[String(nl.status)] ?? 'secondary'
|
||||
NEWSLETTER_STATUS_VARIANT[String(nl.status)] ?? 'secondary'
|
||||
}
|
||||
>
|
||||
{STATUS_LABEL[String(nl.status)] ?? String(nl.status)}
|
||||
{NEWSLETTER_STATUS_LABEL[String(nl.status)] ?? String(nl.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
@@ -169,6 +167,44 @@ export default async function NewsletterPage({ params }: PageProps) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{startIdx + 1}–{Math.min(startIdx + PAGE_SIZE, totalItems)} von {totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{safePage > 1 ? (
|
||||
<Link href={`/home/${account}/newsletter?page=${safePage - 1}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<span className="px-3 text-sm font-medium">
|
||||
{safePage} / {totalPages}
|
||||
</span>
|
||||
|
||||
{safePage < totalPages ? (
|
||||
<Link href={`/home/${account}/newsletter?page=${safePage + 1}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createNewsletterApi } from '@kit/newsletter/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -26,7 +27,7 @@ export default async function NewsletterTemplatesPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createNewsletterApi(client);
|
||||
const templates = await api.listTemplates(acct.id);
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -32,7 +32,9 @@ import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
import { createEventManagementApi } from '@kit/event-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface TeamAccountHomePageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -50,7 +52,7 @@ export default async function TeamAccountHomePage({
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
// Load all stats in parallel with allSettled for resilience
|
||||
const [
|
||||
@@ -157,7 +159,15 @@ export default async function TeamAccountHomePage({
|
||||
href={`/home/${account}/bookings/${String(booking.id)}`}
|
||||
className="text-sm font-medium hover:underline"
|
||||
>
|
||||
Buchung #{String(booking.id).slice(0, 8)}
|
||||
Buchung{' '}
|
||||
{booking.check_in
|
||||
? new Date(
|
||||
String(booking.check_in),
|
||||
).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
})
|
||||
: '—'}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{booking.check_in
|
||||
@@ -213,12 +223,11 @@ export default async function TeamAccountHomePage({
|
||||
))}
|
||||
|
||||
{bookings.data.length === 0 && events.data.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Activity className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Noch keine Aktivitäten vorhanden
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={<Activity className="h-8 w-8" />}
|
||||
title="Noch keine Aktivitäten"
|
||||
description="Aktuelle Buchungen und Veranstaltungen werden hier angezeigt."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -231,69 +240,59 @@ export default async function TeamAccountHomePage({
|
||||
<CardDescription>Häufig verwendete Aktionen</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<Link href={`/home/${account}/members-cms/new`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Neues Mitglied
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Link
|
||||
href={`/home/${account}/members-cms/new`}
|
||||
className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Neues Mitglied
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
<Link href={`/home/${account}/courses/new`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<GraduationCap className="h-4 w-4" />
|
||||
Neuer Kurs
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Link
|
||||
href={`/home/${account}/courses/new`}
|
||||
className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<GraduationCap className="h-4 w-4" />
|
||||
Neuer Kurs
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
<Link href={`/home/${account}/newsletter/new`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
Newsletter erstellen
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Link
|
||||
href={`/home/${account}/newsletter/new`}
|
||||
className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
Newsletter erstellen
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
<Link href={`/home/${account}/bookings/new`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<BedDouble className="h-4 w-4" />
|
||||
Neue Buchung
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Link
|
||||
href={`/home/${account}/bookings/new`}
|
||||
className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<BedDouble className="h-4 w-4" />
|
||||
Neue Buchung
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
<Link href={`/home/${account}/events/new`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Neue Veranstaltung
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Link
|
||||
href={`/home/${account}/events/new`}
|
||||
className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Neue Veranstaltung
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -317,10 +316,11 @@ export default async function TeamAccountHomePage({
|
||||
aktiv
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/home/${account}/bookings`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Link
|
||||
href={`/home/${account}/bookings`}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -343,10 +343,11 @@ export default async function TeamAccountHomePage({
|
||||
aktiv
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/home/${account}/events`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Link
|
||||
href={`/home/${account}/events`}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -366,10 +367,11 @@ export default async function TeamAccountHomePage({
|
||||
von {courseStats.totalCourses} insgesamt
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/home/${account}/courses`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Link
|
||||
href={`/home/${account}/courses`}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createSiteBuilderApi } from '@kit/site-builder/api';
|
||||
import { SiteEditor } from '@kit/site-builder/components';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props { params: Promise<{ account: string; pageId: string }> }
|
||||
|
||||
@@ -8,7 +9,7 @@ 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>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createSiteBuilderApi(client);
|
||||
const page = await api.getPage(pageId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { CreatePageForm } from '@kit/site-builder/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -10,7 +11,7 @@ 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>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neue Seite" description="Seite für Ihre Vereinswebsite erstellen">
|
||||
|
||||
@@ -3,10 +3,12 @@ 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 { cn } from '@kit/ui/utils';
|
||||
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';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
|
||||
@@ -14,14 +16,15 @@ 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>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
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;
|
||||
const isOnline = Boolean(settings?.is_public);
|
||||
const publishedCount = pages.filter((p: Record<string, unknown>) => p.is_published).length;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Website-Baukasten" description="Ihre Vereinswebseite verwalten">
|
||||
@@ -34,7 +37,7 @@ export default async function SiteBuilderDashboard({ params }: Props) {
|
||||
<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 && (
|
||||
{isOnline && (
|
||||
<a href={`/club/${account}`} target="_blank" rel="noopener">
|
||||
<Button variant="outline" size="sm"><ExternalLink className="mr-2 h-4 w-4" />Website ansehen</Button>
|
||||
</a>
|
||||
@@ -48,7 +51,17 @@ export default async function SiteBuilderDashboard({ params }: Props) {
|
||||
<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>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm text-muted-foreground">Status</p>
|
||||
<p className="text-2xl font-bold">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={cn('inline-block h-2 w-2 rounded-full', isOnline ? 'bg-green-500' : 'bg-red-500')} />
|
||||
<span>{isOnline ? 'Online' : 'Offline'}</span>
|
||||
</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{pages.length === 0 ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { CreatePostForm } from '@kit/site-builder/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
|
||||
@@ -8,7 +9,7 @@ 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>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neuer Beitrag" description="Beitrag erstellen">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Plus } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
|
||||
@@ -14,7 +15,7 @@ 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>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createSiteBuilderApi(client);
|
||||
const posts = await api.listPosts(acct.id);
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props { params: Promise<{ account: string }> }
|
||||
|
||||
@@ -9,7 +10,7 @@ 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>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createSiteBuilderApi(client);
|
||||
const settings = await api.getSiteSettings(acct.id);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
|
||||
import {
|
||||
VerbandTabNavigation,
|
||||
ClubContactsManager,
|
||||
ClubFeeBillingTable,
|
||||
ClubNotesList,
|
||||
} from '@kit/verbandsverwaltung/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string; clubId: string }>;
|
||||
}
|
||||
|
||||
export default async function ClubDetailPage({ params }: Props) {
|
||||
const { account, clubId } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createVerbandApi(client);
|
||||
const detail = await api.getClubDetail(clubId);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={`Verein – ${detail.club.name}`}>
|
||||
<VerbandTabNavigation account={account} activeTab="clubs" />
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Club Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{detail.club.name}</h1>
|
||||
{detail.club.short_name && (
|
||||
<p className="text-muted-foreground">{detail.club.short_name}</p>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap gap-4 text-sm text-muted-foreground">
|
||||
{detail.club.city && (
|
||||
<span>{detail.club.zip} {detail.club.city}</span>
|
||||
)}
|
||||
{detail.club.member_count != null && (
|
||||
<span>{detail.club.member_count} Mitglieder</span>
|
||||
)}
|
||||
{detail.club.founded_year && (
|
||||
<span>Gegr. {detail.club.founded_year}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contacts */}
|
||||
<ClubContactsManager clubId={clubId} contacts={detail.contacts} />
|
||||
|
||||
{/* Fee Billings */}
|
||||
<ClubFeeBillingTable billings={detail.billings} clubId={clubId} />
|
||||
|
||||
{/* Notes */}
|
||||
<ClubNotesList notes={detail.notes} clubId={clubId} />
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
|
||||
import { VerbandTabNavigation, CreateClubForm } from '@kit/verbandsverwaltung/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewClubPage({ 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 <AccountNotFound />;
|
||||
|
||||
const api = createVerbandApi(client);
|
||||
const types = await api.listTypes(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neuer Verein">
|
||||
<VerbandTabNavigation account={account} activeTab="clubs" />
|
||||
<CreateClubForm
|
||||
accountId={acct.id}
|
||||
account={account}
|
||||
types={types.map((t) => ({ id: t.id, name: t.name }))}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
54
apps/web/app/[locale]/home/[account]/verband/clubs/page.tsx
Normal file
54
apps/web/app/[locale]/home/[account]/verband/clubs/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
|
||||
import { VerbandTabNavigation, ClubsDataTable } from '@kit/verbandsverwaltung/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function ClubsPage({ 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 <AccountNotFound />;
|
||||
|
||||
const api = createVerbandApi(client);
|
||||
const page = Number(search.page) || 1;
|
||||
const showArchived = search.archived === '1';
|
||||
|
||||
const [result, types] = await Promise.all([
|
||||
api.listClubs(acct.id, {
|
||||
search: search.q as string,
|
||||
typeId: search.type as string,
|
||||
archived: showArchived ? undefined : false,
|
||||
page,
|
||||
pageSize: 25,
|
||||
}),
|
||||
api.listTypes(acct.id),
|
||||
]);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Verbandsverwaltung - Vereine">
|
||||
<VerbandTabNavigation account={account} activeTab="clubs" />
|
||||
<ClubsDataTable
|
||||
data={result.data}
|
||||
total={result.total}
|
||||
page={page}
|
||||
pageSize={25}
|
||||
account={account}
|
||||
types={types.map((t) => ({ id: t.id, name: t.name }))}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
5
apps/web/app/[locale]/home/[account]/verband/layout.tsx
Normal file
5
apps/web/app/[locale]/home/[account]/verband/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export default function VerbandLayout({ children }: { children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
33
apps/web/app/[locale]/home/[account]/verband/page.tsx
Normal file
33
apps/web/app/[locale]/home/[account]/verband/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
|
||||
import { VerbandTabNavigation, VerbandDashboard } from '@kit/verbandsverwaltung/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function VerbandPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createVerbandApi(client);
|
||||
const stats = await api.getDashboardStats(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Verbandsverwaltung">
|
||||
<VerbandTabNavigation account={account} activeTab="overview" />
|
||||
<VerbandDashboard stats={stats} account={account} />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
import { Plus, Pencil, Trash2, Settings } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import {
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
createAssociationType,
|
||||
updateAssociationType,
|
||||
deleteAssociationType,
|
||||
createFeeType,
|
||||
updateFeeType,
|
||||
deleteFeeType,
|
||||
} from '@kit/verbandsverwaltung/actions/verband-actions';
|
||||
|
||||
interface SettingsContentProps {
|
||||
accountId: string;
|
||||
roles: Array<Record<string, unknown>>;
|
||||
types: Array<Record<string, unknown>>;
|
||||
feeTypes: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function SettingsSection({
|
||||
title,
|
||||
items,
|
||||
onAdd,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
isAdding,
|
||||
isUpdating,
|
||||
}: {
|
||||
title: string;
|
||||
items: Array<Record<string, unknown>>;
|
||||
onAdd: (name: string, description?: string) => void;
|
||||
onUpdate: (id: string, name: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
isAdding: boolean;
|
||||
isUpdating: boolean;
|
||||
}) {
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Settings className="h-4 w-4" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
{!showAdd && (
|
||||
<Button size="sm" onClick={() => setShowAdd(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Hinzufügen
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{showAdd && (
|
||||
<div className="mb-4 flex gap-2 rounded-lg border p-3">
|
||||
<Input
|
||||
placeholder="Name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Beschreibung (optional)"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!newName.trim() || isAdding}
|
||||
onClick={() => {
|
||||
onAdd(newName.trim(), newDesc.trim() || undefined);
|
||||
setNewName('');
|
||||
setNewDesc('');
|
||||
setShowAdd(false);
|
||||
}}
|
||||
>
|
||||
Erstellen
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowAdd(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Einträge vorhanden.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<div key={String(item.id)} className="flex items-center justify-between rounded-lg border p-3">
|
||||
{editingId === String(item.id) ? (
|
||||
<div className="flex flex-1 gap-2">
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!editName.trim() || isUpdating}
|
||||
onClick={() => {
|
||||
onUpdate(String(item.id), editName.trim());
|
||||
setEditingId(null);
|
||||
}}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditingId(null)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<span className="font-medium">{String(item.name)}</span>
|
||||
{item.description && (
|
||||
<p className="text-xs text-muted-foreground">{String(item.description)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingId(String(item.id));
|
||||
setEditName(String(item.name));
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(String(item.id))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsContent({
|
||||
accountId,
|
||||
roles,
|
||||
types,
|
||||
feeTypes,
|
||||
}: SettingsContentProps) {
|
||||
// Roles
|
||||
const { execute: execCreateRole, isPending: isCreatingRole } = useAction(createRole, {
|
||||
onSuccess: () => toast.success('Funktion erstellt'),
|
||||
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
|
||||
});
|
||||
const { execute: execUpdateRole, isPending: isUpdatingRole } = useAction(updateRole, {
|
||||
onSuccess: () => toast.success('Funktion aktualisiert'),
|
||||
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
|
||||
});
|
||||
const { execute: execDeleteRole } = useAction(deleteRole, {
|
||||
onSuccess: () => toast.success('Funktion gelöscht'),
|
||||
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
|
||||
});
|
||||
|
||||
// Types
|
||||
const { execute: execCreateType, isPending: isCreatingType } = useAction(createAssociationType, {
|
||||
onSuccess: () => toast.success('Vereinstyp erstellt'),
|
||||
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
|
||||
});
|
||||
const { execute: execUpdateType, isPending: isUpdatingType } = useAction(updateAssociationType, {
|
||||
onSuccess: () => toast.success('Vereinstyp aktualisiert'),
|
||||
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
|
||||
});
|
||||
const { execute: execDeleteType } = useAction(deleteAssociationType, {
|
||||
onSuccess: () => toast.success('Vereinstyp gelöscht'),
|
||||
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
|
||||
});
|
||||
|
||||
// Fee Types
|
||||
const { execute: execCreateFeeType, isPending: isCreatingFee } = useAction(createFeeType, {
|
||||
onSuccess: () => toast.success('Beitragsart erstellt'),
|
||||
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
|
||||
});
|
||||
const { execute: execUpdateFeeType, isPending: isUpdatingFee } = useAction(updateFeeType, {
|
||||
onSuccess: () => toast.success('Beitragsart aktualisiert'),
|
||||
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
|
||||
});
|
||||
const { execute: execDeleteFeeType } = useAction(deleteFeeType, {
|
||||
onSuccess: () => toast.success('Beitragsart gelöscht'),
|
||||
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Einstellungen</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Funktionen, Vereinstypen und Beitragsarten verwalten
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title="Funktionen (Rollen)"
|
||||
items={roles}
|
||||
onAdd={(name, description) => execCreateRole({ accountId, name, description, sortOrder: 0 })}
|
||||
onUpdate={(id, name) => execUpdateRole({ roleId: id, name })}
|
||||
onDelete={(id) => execDeleteRole({ roleId: id })}
|
||||
isAdding={isCreatingRole}
|
||||
isUpdating={isUpdatingRole}
|
||||
/>
|
||||
|
||||
<SettingsSection
|
||||
title="Vereinstypen"
|
||||
items={types}
|
||||
onAdd={(name, description) => execCreateType({ accountId, name, description, sortOrder: 0 })}
|
||||
onUpdate={(id, name) => execUpdateType({ typeId: id, name })}
|
||||
onDelete={(id) => execDeleteType({ typeId: id })}
|
||||
isAdding={isCreatingType}
|
||||
isUpdating={isUpdatingType}
|
||||
/>
|
||||
|
||||
<SettingsSection
|
||||
title="Beitragsarten"
|
||||
items={feeTypes}
|
||||
onAdd={(name, description) => execCreateFeeType({ accountId, name, description, isActive: true })}
|
||||
onUpdate={(id, name) => execUpdateFeeType({ feeTypeId: id, name })}
|
||||
onDelete={(id) => execDeleteFeeType({ feeTypeId: id })}
|
||||
isAdding={isCreatingFee}
|
||||
isUpdating={isUpdatingFee}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
|
||||
import { VerbandTabNavigation } from '@kit/verbandsverwaltung/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
import SettingsContent from './_components/settings-content';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function SettingsPage({ 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 <AccountNotFound />;
|
||||
|
||||
const api = createVerbandApi(client);
|
||||
const [roles, types, feeTypes] = await Promise.all([
|
||||
api.listRoles(acct.id),
|
||||
api.listTypes(acct.id),
|
||||
api.listFeeTypes(acct.id),
|
||||
]);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Verbandsverwaltung - Einstellungen">
|
||||
<VerbandTabNavigation account={account} activeTab="settings" />
|
||||
<SettingsContent
|
||||
accountId={acct.id}
|
||||
roles={roles}
|
||||
types={types}
|
||||
feeTypes={feeTypes}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
const PLACEHOLDER_DATA = [
|
||||
{ year: '2020', vereine: 12, mitglieder: 850 },
|
||||
{ year: '2021', vereine: 14, mitglieder: 920 },
|
||||
{ year: '2022', vereine: 15, mitglieder: 980 },
|
||||
{ year: '2023', vereine: 16, mitglieder: 1050 },
|
||||
{ year: '2024', vereine: 18, mitglieder: 1120 },
|
||||
{ year: '2025', vereine: 19, mitglieder: 1200 },
|
||||
];
|
||||
|
||||
export default function StatisticsContent() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Statistik</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Entwicklung der Mitgliedsvereine und Gesamtmitglieder im Zeitverlauf
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Vereinsentwicklung
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={PLACEHOLDER_DATA}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="year" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="vereine"
|
||||
stroke="hsl(var(--primary))"
|
||||
fill="hsl(var(--primary))"
|
||||
fillOpacity={0.1}
|
||||
name="Vereine"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Mitgliederentwicklung
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={PLACEHOLDER_DATA}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="year" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="mitglieder"
|
||||
stroke="hsl(var(--chart-2))"
|
||||
fill="hsl(var(--chart-2))"
|
||||
fillOpacity={0.1}
|
||||
name="Mitglieder"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Die Statistiken werden automatisch aus den Vereinsdaten und der Verbandshistorie berechnet.
|
||||
Pflegen Sie die Mitgliederzahlen in den einzelnen Vereinsdetails, um aktuelle Auswertungen zu erhalten.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { VerbandTabNavigation } from '@kit/verbandsverwaltung/components';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
import StatisticsContent from './_components/statistics-content';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function StatisticsPage({ params }: Props) {
|
||||
const { account } = await params;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Verbandsverwaltung - Statistik">
|
||||
<VerbandTabNavigation account={account} activeTab="statistics" />
|
||||
<StatisticsContent />
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
52
apps/web/app/api/club/course-register/route.ts
Normal file
52
apps/web/app/api/club/course-register/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { courseId, firstName, lastName, email, phone } = body;
|
||||
|
||||
if (!courseId || !firstName || !lastName || !email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kurs-ID, Vorname, Nachname und E-Mail sind erforderlich' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ungültige E-Mail-Adresse' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = getSupabaseServerAdminClient();
|
||||
|
||||
const { error } = await supabase.from('course_participants').insert({
|
||||
course_id: courseId,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email,
|
||||
phone: phone || null,
|
||||
status: 'enrolled',
|
||||
enrolled_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('[course-register] Insert error:', error.message);
|
||||
return NextResponse.json(
|
||||
{ error: 'Anmeldung fehlgeschlagen' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Anmeldung erfolgreich',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[course-register] Error:', err);
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
64
apps/web/app/api/club/event-register/route.ts
Normal file
64
apps/web/app/api/club/event-register/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const {
|
||||
eventId,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phone,
|
||||
dateOfBirth,
|
||||
parentName,
|
||||
parentPhone,
|
||||
} = body;
|
||||
|
||||
if (!eventId || !firstName || !lastName || !email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Event-ID, Vorname, Nachname und E-Mail sind erforderlich' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ungültige E-Mail-Adresse' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = getSupabaseServerAdminClient();
|
||||
|
||||
const { error } = await supabase.from('event_registrations').insert({
|
||||
event_id: eventId,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email,
|
||||
phone: phone || null,
|
||||
date_of_birth: dateOfBirth || null,
|
||||
parent_name: parentName || null,
|
||||
parent_phone: parentPhone || null,
|
||||
status: 'registered',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('[event-register] Insert error:', error.message);
|
||||
return NextResponse.json(
|
||||
{ error: 'Anmeldung fehlgeschlagen' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Anmeldung erfolgreich',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[event-register] Error:', err);
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
70
apps/web/app/api/club/membership-apply/route.ts
Normal file
70
apps/web/app/api/club/membership-apply/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const {
|
||||
accountId,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phone,
|
||||
street,
|
||||
postalCode,
|
||||
city,
|
||||
dateOfBirth,
|
||||
message,
|
||||
} = body;
|
||||
|
||||
if (!accountId || !firstName || !lastName || !email) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Konto-ID, Vorname, Nachname und E-Mail sind erforderlich',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ungültige E-Mail-Adresse' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = getSupabaseServerAdminClient();
|
||||
|
||||
const { error } = await supabase.from('membership_applications').insert({
|
||||
account_id: accountId,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email,
|
||||
phone: phone || null,
|
||||
street: street || null,
|
||||
postal_code: postalCode || null,
|
||||
city: city || null,
|
||||
date_of_birth: dateOfBirth || null,
|
||||
message: message || null,
|
||||
status: 'submitted',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('[membership-apply] Insert error:', error.message);
|
||||
return NextResponse.json(
|
||||
{ error: 'Bewerbung fehlgeschlagen' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Bewerbung erfolgreich eingereicht',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[membership-apply] Error:', err);
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,10 @@ async function getSupabaseHealthCheck() {
|
||||
const { data, error } = await client
|
||||
.from('config')
|
||||
.select('billing_provider')
|
||||
.single();
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
return !error && Boolean(data?.billing_provider);
|
||||
return !error;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
22
apps/web/components/account-not-found.tsx
Normal file
22
apps/web/components/account-not-found.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import Link from 'next/link';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
export function AccountNotFound() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="mb-4 rounded-full bg-destructive/10 p-4">
|
||||
<AlertTriangle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">Konto nicht gefunden</h2>
|
||||
<p className="mt-2 max-w-md text-sm text-muted-foreground">
|
||||
Das angeforderte Konto existiert nicht oder Sie haben keine Berechtigung darauf zuzugreifen.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link href="/home">
|
||||
<Button variant="outline">Zum Dashboard</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
apps/web/components/confirm-dialog.tsx
Normal file
55
apps/web/components/confirm-dialog.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@kit/ui/alert-dialog';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
trigger: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Bestätigen',
|
||||
cancelLabel = 'Abbrechen',
|
||||
variant = 'default',
|
||||
onConfirm,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={trigger as React.ReactElement}>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
className={variant === 'destructive' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
|
||||
>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,9 @@ const FeatureFlagsSchema = z.object({
|
||||
enableNewsletter: z.boolean().default(true),
|
||||
enableGdprCompliance: z.boolean().default(true),
|
||||
enableSiteBuilder: z.boolean().default(true),
|
||||
enableFischerei: z.boolean().default(false),
|
||||
enableMeetingProtocols: z.boolean().default(false),
|
||||
enableVerbandsverwaltung: z.boolean().default(false),
|
||||
});
|
||||
|
||||
const featuresFlagConfig = FeatureFlagsSchema.parse({
|
||||
@@ -137,6 +140,18 @@ const featuresFlagConfig = FeatureFlagsSchema.parse({
|
||||
process.env.NEXT_PUBLIC_ENABLE_SITE_BUILDER,
|
||||
true,
|
||||
),
|
||||
enableFischerei: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_FISCHEREI,
|
||||
false,
|
||||
),
|
||||
enableMeetingProtocols: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_MEETING_PROTOCOLS,
|
||||
false,
|
||||
),
|
||||
enableVerbandsverwaltung: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG,
|
||||
false,
|
||||
),
|
||||
} satisfies z.output<typeof FeatureFlagsSchema>);
|
||||
|
||||
export default featuresFlagConfig;
|
||||
|
||||
@@ -31,6 +31,9 @@ const PathsSchema = z.object({
|
||||
accountDocuments: z.string().min(1),
|
||||
accountNewsletter: z.string().min(1),
|
||||
accountSiteBuilder: z.string().min(1),
|
||||
accountFischerei: z.string().min(1),
|
||||
accountMeetings: z.string().min(1),
|
||||
accountVerband: z.string().min(1),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -65,6 +68,9 @@ const pathsConfig = PathsSchema.parse({
|
||||
accountDocuments: `/home/[account]/documents`,
|
||||
accountNewsletter: `/home/[account]/newsletter`,
|
||||
accountSiteBuilder: `/home/[account]/site-builder`,
|
||||
accountFischerei: `/home/[account]/fischerei`,
|
||||
accountMeetings: '/home/[account]/meetings',
|
||||
accountVerband: '/home/[account]/verband',
|
||||
},
|
||||
} satisfies z.output<typeof PathsSchema>);
|
||||
|
||||
|
||||
@@ -170,7 +170,34 @@
|
||||
"holidayPasses": "Ferienpässe",
|
||||
"eventDate": "Datum",
|
||||
"eventLocation": "Ort",
|
||||
"capacity": "Plätze"
|
||||
"capacity": "Plätze",
|
||||
"allEvents": "Alle Veranstaltungen",
|
||||
"locations": "Orte",
|
||||
"totalCapacity": "Kapazität gesamt",
|
||||
"noEvents": "Keine Veranstaltungen vorhanden",
|
||||
"noEventsDescription": "Erstellen Sie Ihre erste Veranstaltung, um loszulegen.",
|
||||
"name": "Name",
|
||||
"status": "Status",
|
||||
"paginationPage": "Seite {page} von {totalPages}",
|
||||
"paginationPrevious": "Vorherige",
|
||||
"paginationNext": "Nächste",
|
||||
"registrationsOverview": "Anmeldungen aller Veranstaltungen im Überblick",
|
||||
"totalRegistrations": "Anmeldungen gesamt",
|
||||
"withRegistrations": "Mit Anmeldungen",
|
||||
"overviewByEvent": "Übersicht nach Veranstaltung",
|
||||
"noEventsForRegistrations": "Erstellen Sie eine Veranstaltung, um Anmeldungen zu erhalten.",
|
||||
"utilization": "Auslastung",
|
||||
"event": "Veranstaltung",
|
||||
"holidayPassesDescription": "Ferienpässe und Ferienprogramme verwalten",
|
||||
"newHolidayPass": "Neuer Ferienpass",
|
||||
"noHolidayPasses": "Keine Ferienpässe vorhanden",
|
||||
"noHolidayPassesDescription": "Erstellen Sie Ihren ersten Ferienpass.",
|
||||
"allHolidayPasses": "Alle Ferienpässe",
|
||||
"year": "Jahr",
|
||||
"price": "Preis",
|
||||
"validFrom": "Gültig von",
|
||||
"validUntil": "Gültig bis",
|
||||
"newEventDescription": "Veranstaltung oder Ferienprogramm anlegen"
|
||||
},
|
||||
"finance": {
|
||||
"title": "Finanzen",
|
||||
@@ -259,7 +286,15 @@
|
||||
"finance.write": "Finanzen bearbeiten",
|
||||
"finance.sepa": "SEPA-Einzüge ausführen",
|
||||
"documents.generate": "Dokumente generieren",
|
||||
"newsletter.send": "Newsletter versenden"
|
||||
"newsletter.send": "Newsletter versenden",
|
||||
"fischerei.read": "Fischerei lesen",
|
||||
"fischerei.write": "Fischerei bearbeiten",
|
||||
"meetings.read": "Sitzungsprotokolle lesen",
|
||||
"meetings.write": "Sitzungsprotokolle bearbeiten",
|
||||
"meetings.delete": "Sitzungsprotokolle löschen",
|
||||
"verband.read": "Verbandsverwaltung lesen",
|
||||
"verband.write": "Verbandsverwaltung bearbeiten",
|
||||
"verband.delete": "Verbandsverwaltung löschen"
|
||||
},
|
||||
"status": {
|
||||
"active": "Aktiv",
|
||||
@@ -267,5 +302,477 @@
|
||||
"archived": "Archiviert",
|
||||
"locked": "Gesperrt",
|
||||
"deleted": "Gelöscht"
|
||||
},
|
||||
"fischerei": {
|
||||
"title": "Fischerei",
|
||||
"description": "Gewässer, Fischarten, Besatz, Pachten, Fangbücher und Wettbewerbe verwalten",
|
||||
"dashboard": {
|
||||
"title": "Übersicht",
|
||||
"watersCount": "Gewässer",
|
||||
"speciesCount": "Fischarten",
|
||||
"activeLeases": "Aktive Pachten",
|
||||
"pendingCatchBooks": "Offene Fangbücher",
|
||||
"upcomingCompetitions": "Kommende Wettbewerbe",
|
||||
"stockingCostYtd": "Besatzkosten (lfd. Jahr)",
|
||||
"recentStocking": "Letzte Besatzaktionen",
|
||||
"pendingReview": "Zur Prüfung ausstehend"
|
||||
},
|
||||
"waters": {
|
||||
"title": "Gewässer",
|
||||
"newWater": "Neues Gewässer",
|
||||
"editWater": "Gewässer bearbeiten",
|
||||
"name": "Name",
|
||||
"shortName": "Kurzname",
|
||||
"waterType": "Gewässertyp",
|
||||
"description": "Beschreibung",
|
||||
"surfaceArea": "Fläche (ha)",
|
||||
"length": "Länge (m)",
|
||||
"width": "Breite (m)",
|
||||
"avgDepth": "Durchschnittstiefe (m)",
|
||||
"maxDepth": "Maximaltiefe (m)",
|
||||
"outflow": "Abfluss",
|
||||
"location": "Lage/Standort",
|
||||
"county": "Landkreis",
|
||||
"gpsCoordinates": "GPS-Koordinaten",
|
||||
"lfvNumber": "LFV-Nummer",
|
||||
"lfvName": "LFV-Name",
|
||||
"costShares": "Kostenanteile",
|
||||
"electrofishing": "Elektrofischerei-Genehmigung beantragt",
|
||||
"costCenter": "Kostenstelle",
|
||||
"archived": "Archiviert",
|
||||
"showArchived": "Archivierte anzeigen",
|
||||
"speciesRules": "Fischarten & Regelungen",
|
||||
"stockingHistory": "Besatzhistorie",
|
||||
"leases": "Pachten",
|
||||
"inspectors": "Kontrolleure",
|
||||
"map": "Karte",
|
||||
"catchStats": "Fangstatistik",
|
||||
"waterTypes": {
|
||||
"fluss": "Fluss",
|
||||
"bach": "Bach",
|
||||
"see": "See",
|
||||
"teich": "Teich",
|
||||
"weiher": "Weiher",
|
||||
"kanal": "Kanal",
|
||||
"stausee": "Stausee",
|
||||
"baggersee": "Baggersee",
|
||||
"sonstige": "Sonstige"
|
||||
},
|
||||
"basicData": "Grunddaten",
|
||||
"dimensions": "Abmessungen",
|
||||
"geography": "Geografie",
|
||||
"administration": "Verwaltung"
|
||||
},
|
||||
"species": {
|
||||
"title": "Fischarten",
|
||||
"newSpecies": "Neue Fischart",
|
||||
"editSpecies": "Fischart bearbeiten",
|
||||
"name": "Name",
|
||||
"nameLatin": "Lateinischer Name",
|
||||
"nameLocal": "Lokaler Name",
|
||||
"maxAge": "Max. Alter (Jahre)",
|
||||
"maxWeight": "Max. Gewicht (kg)",
|
||||
"maxLength": "Max. Länge (cm)",
|
||||
"protectedMinSize": "Schonmaß (cm)",
|
||||
"protectionPeriod": "Schonzeit",
|
||||
"protectionStart": "Schonzeit Beginn (MM.TT)",
|
||||
"protectionEnd": "Schonzeit Ende (MM.TT)",
|
||||
"spawningSeason": "Sonderschonzeit (SZG)",
|
||||
"kFactor": "K-Faktor",
|
||||
"kFactorAvg": "K-Faktor (Durchschnitt)",
|
||||
"kFactorMin": "K-Faktor (Min)",
|
||||
"kFactorMax": "K-Faktor (Max)",
|
||||
"pricePerUnit": "Preis pro Einheit",
|
||||
"maxCatchPerDay": "Max. Fang/Tag",
|
||||
"maxCatchPerYear": "Max. Fang/Jahr",
|
||||
"individualRecording": "Einzelerfassung",
|
||||
"active": "Aktiv",
|
||||
"biometrics": "Biometrische Daten",
|
||||
"protection": "Schutzbestimmungen",
|
||||
"quotas": "Fangbegrenzungen"
|
||||
},
|
||||
"stocking": {
|
||||
"title": "Besatz",
|
||||
"newStocking": "Besatz eintragen",
|
||||
"date": "Besatzdatum",
|
||||
"water": "Gewässer",
|
||||
"species": "Fischart",
|
||||
"quantity": "Anzahl (Stück)",
|
||||
"weight": "Gewicht (kg)",
|
||||
"ageClass": "Altersklasse",
|
||||
"cost": "Kosten (EUR)",
|
||||
"supplier": "Lieferant",
|
||||
"remarks": "Bemerkungen",
|
||||
"ageClasses": {
|
||||
"brut": "Brut",
|
||||
"soemmerlinge": "Sömmerlinge",
|
||||
"einsoemmerig": "1-sömmrig",
|
||||
"zweisoemmerig": "2-sömmrig",
|
||||
"dreisoemmerig": "3-sömmrig",
|
||||
"vorgestreckt": "Vorgestreckt",
|
||||
"setzlinge": "Setzlinge",
|
||||
"laichfische": "Laichfische",
|
||||
"sonstige": "Sonstige"
|
||||
},
|
||||
"totalCost": "Gesamtkosten",
|
||||
"totalQuantity": "Gesamtmenge"
|
||||
},
|
||||
"leases": {
|
||||
"title": "Pachten",
|
||||
"newLease": "Neue Pacht",
|
||||
"editLease": "Pacht bearbeiten",
|
||||
"lessor": "Verpächter",
|
||||
"lessorAddress": "Adresse des Verpächters",
|
||||
"startDate": "Beginn",
|
||||
"endDate": "Ende",
|
||||
"duration": "Laufzeit (Jahre)",
|
||||
"initialAmount": "Anfangsbetrag (EUR)",
|
||||
"fixedIncrease": "Feste jährl. Erhöhung (EUR)",
|
||||
"percentageIncrease": "Prozentuale jährl. Erhöhung (%)",
|
||||
"paymentMethod": "Zahlungsart",
|
||||
"specialAgreements": "Sondervereinbarungen",
|
||||
"currentAmount": "Aktueller Jahresbetrag",
|
||||
"paymentMethods": {
|
||||
"bar": "Bar",
|
||||
"lastschrift": "Lastschrift",
|
||||
"ueberweisung": "Überweisung"
|
||||
}
|
||||
},
|
||||
"catchBooks": {
|
||||
"title": "Fangbücher",
|
||||
"newCatchBook": "Neues Fangbuch",
|
||||
"member": "Mitglied",
|
||||
"year": "Jahr",
|
||||
"fishingDays": "Angeltage",
|
||||
"totalCatches": "Gesamtfänge",
|
||||
"status": "Status",
|
||||
"verification": "Bewertung",
|
||||
"submit": "Einreichen",
|
||||
"review": "Prüfen",
|
||||
"approve": "Akzeptieren",
|
||||
"reject": "Ablehnen",
|
||||
"submitted": "Eingereicht am",
|
||||
"checked": "Geprüft",
|
||||
"flyFisher": "Fliegenfischer",
|
||||
"cardNumbers": "Erlaubnisschein-Nr.",
|
||||
"statuses": {
|
||||
"offen": "Offen",
|
||||
"eingereicht": "Eingereicht",
|
||||
"geprueft": "Geprüft",
|
||||
"akzeptiert": "Akzeptiert",
|
||||
"abgelehnt": "Abgelehnt"
|
||||
},
|
||||
"verifications": {
|
||||
"sehrgut": "Sehr gut",
|
||||
"gut": "Gut",
|
||||
"ok": "OK",
|
||||
"schlecht": "Schlecht",
|
||||
"falsch": "Falsch",
|
||||
"leer": "Leer"
|
||||
}
|
||||
},
|
||||
"catches": {
|
||||
"title": "Fänge",
|
||||
"newCatch": "Fang eintragen",
|
||||
"date": "Datum",
|
||||
"species": "Fischart",
|
||||
"water": "Gewässer",
|
||||
"quantity": "Anzahl",
|
||||
"length": "Länge (cm)",
|
||||
"weight": "Gewicht (g)",
|
||||
"kFactor": "K-Faktor",
|
||||
"sizeCategory": "Größenkategorie",
|
||||
"gender": "Geschlecht",
|
||||
"permit": "Erlaubnisschein"
|
||||
},
|
||||
"permits": {
|
||||
"title": "Erlaubnisscheine",
|
||||
"newPermit": "Neuer Erlaubnisschein",
|
||||
"name": "Bezeichnung",
|
||||
"shortCode": "Kurzcode",
|
||||
"primaryWater": "Hauptgewässer",
|
||||
"totalQuantity": "Gesamtmenge",
|
||||
"forSale": "Zum Verkauf",
|
||||
"quotas": "Kontingente"
|
||||
},
|
||||
"inspectors": {
|
||||
"title": "Gewässer-Kontrolleure",
|
||||
"assignInspector": "Kontrolleur zuweisen",
|
||||
"removeInspector": "Kontrolleur entfernen",
|
||||
"assignmentStart": "Beginn",
|
||||
"assignmentEnd": "Ende"
|
||||
},
|
||||
"competitions": {
|
||||
"title": "Wettbewerbe",
|
||||
"newCompetition": "Neuer Wettbewerb",
|
||||
"name": "Bezeichnung",
|
||||
"date": "Datum",
|
||||
"water": "Gewässer",
|
||||
"maxParticipants": "Max. Teilnehmer",
|
||||
"scoring": "Wertung",
|
||||
"scoreByCount": "Nach Anzahl",
|
||||
"scoreByHeaviest": "Nach Schwerster",
|
||||
"scoreByTotalWeight": "Nach Gesamtgewicht",
|
||||
"scoreByLongest": "Nach Längstem",
|
||||
"scoreByTotalLength": "Nach Gesamtlänge",
|
||||
"participants": "Teilnehmer",
|
||||
"addParticipant": "Teilnehmer hinzufügen",
|
||||
"results": "Ergebnisse",
|
||||
"computeResults": "Ergebnisse berechnen",
|
||||
"categories": "Kategorien",
|
||||
"rank": "Platz"
|
||||
},
|
||||
"suppliers": {
|
||||
"title": "Lieferanten",
|
||||
"newSupplier": "Neuer Lieferant",
|
||||
"name": "Name",
|
||||
"contactPerson": "Ansprechpartner",
|
||||
"phone": "Telefon",
|
||||
"email": "E-Mail",
|
||||
"address": "Adresse"
|
||||
},
|
||||
"statistics": {
|
||||
"title": "Statistiken",
|
||||
"catchesBySpecies": "Fänge nach Fischart",
|
||||
"catchesByWater": "Fänge nach Gewässer",
|
||||
"catchesByYear": "Fänge nach Jahr",
|
||||
"stockingOverview": "Besatzübersicht",
|
||||
"totalCatches": "Gesamtfänge",
|
||||
"totalWeight": "Gesamtgewicht (kg)",
|
||||
"avgLength": "Durchschn. Länge (cm)",
|
||||
"avgKFactor": "Durchschn. K-Faktor",
|
||||
"filterYear": "Jahr filtern",
|
||||
"filterWater": "Gewässer filtern"
|
||||
},
|
||||
"export": {
|
||||
"exportStocking": "Besatz exportieren",
|
||||
"exportCatches": "Fänge exportieren",
|
||||
"formatCsv": "CSV",
|
||||
"formatExcel": "Excel"
|
||||
}
|
||||
},
|
||||
"meetings": {
|
||||
"title": "Sitzungsprotokolle",
|
||||
"description": "Sitzungen, Tagesordnungspunkte und Beschlüsse verwalten",
|
||||
"dashboard": {
|
||||
"title": "Übersicht",
|
||||
"totalMeetings": "Sitzungen gesamt",
|
||||
"openDecisions": "Offene Beschlüsse",
|
||||
"upcomingMeetings": "Kommende Sitzungen",
|
||||
"recentProtocols": "Letzte Protokolle"
|
||||
},
|
||||
"bodies": {
|
||||
"title": "Gremien",
|
||||
"newBody": "Neues Gremium",
|
||||
"editBody": "Gremium bearbeiten",
|
||||
"deleteBody": "Gremium löschen",
|
||||
"name": "Name",
|
||||
"shortName": "Kurzname",
|
||||
"description": "Beschreibung",
|
||||
"chairperson": "Vorsitzende(r)",
|
||||
"members": "Mitglieder",
|
||||
"meetingCycle": "Sitzungszyklus",
|
||||
"active": "Aktiv",
|
||||
"cycles": {
|
||||
"weekly": "Wöchentlich",
|
||||
"biweekly": "Zweiwöchentlich",
|
||||
"monthly": "Monatlich",
|
||||
"quarterly": "Vierteljährlich",
|
||||
"biannual": "Halbjährlich",
|
||||
"annual": "Jährlich",
|
||||
"asNeeded": "Nach Bedarf"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Sitzungen",
|
||||
"newSession": "Neue Sitzung",
|
||||
"editSession": "Sitzung bearbeiten",
|
||||
"deleteSession": "Sitzung löschen",
|
||||
"body": "Gremium",
|
||||
"date": "Datum",
|
||||
"startTime": "Beginn",
|
||||
"endTime": "Ende",
|
||||
"location": "Ort",
|
||||
"status": "Status",
|
||||
"agenda": "Tagesordnung",
|
||||
"protocol": "Protokoll",
|
||||
"attendees": "Anwesende",
|
||||
"absentees": "Abwesende",
|
||||
"guests": "Gäste",
|
||||
"recorder": "Protokollführer(in)",
|
||||
"statuses": {
|
||||
"planned": "Geplant",
|
||||
"inProgress": "Laufend",
|
||||
"completed": "Abgeschlossen",
|
||||
"cancelled": "Abgesagt"
|
||||
}
|
||||
},
|
||||
"agendaItems": {
|
||||
"title": "Tagesordnungspunkte",
|
||||
"newItem": "Neuer TOP",
|
||||
"editItem": "TOP bearbeiten",
|
||||
"deleteItem": "TOP löschen",
|
||||
"number": "TOP-Nr.",
|
||||
"subject": "Betreff",
|
||||
"description": "Beschreibung",
|
||||
"presenter": "Berichterstatter(in)",
|
||||
"duration": "Dauer (Min.)",
|
||||
"type": "Art",
|
||||
"attachments": "Anlagen",
|
||||
"notes": "Notizen",
|
||||
"types": {
|
||||
"information": "Information",
|
||||
"discussion": "Diskussion",
|
||||
"decision": "Beschluss",
|
||||
"election": "Wahl",
|
||||
"report": "Bericht",
|
||||
"miscellaneous": "Verschiedenes"
|
||||
}
|
||||
},
|
||||
"decisions": {
|
||||
"title": "Beschlüsse",
|
||||
"newDecision": "Neuer Beschluss",
|
||||
"editDecision": "Beschluss bearbeiten",
|
||||
"deleteDecision": "Beschluss löschen",
|
||||
"number": "Beschluss-Nr.",
|
||||
"subject": "Betreff",
|
||||
"text": "Beschlusstext",
|
||||
"result": "Ergebnis",
|
||||
"votesFor": "Ja-Stimmen",
|
||||
"votesAgainst": "Nein-Stimmen",
|
||||
"abstentions": "Enthaltungen",
|
||||
"responsible": "Verantwortlich",
|
||||
"deadline": "Frist",
|
||||
"status": "Status",
|
||||
"remarks": "Bemerkungen",
|
||||
"results": {
|
||||
"accepted": "Angenommen",
|
||||
"rejected": "Abgelehnt",
|
||||
"deferred": "Vertagt",
|
||||
"withdrawn": "Zurückgezogen"
|
||||
},
|
||||
"statuses": {
|
||||
"open": "Offen",
|
||||
"inProgress": "In Bearbeitung",
|
||||
"completed": "Erledigt",
|
||||
"overdue": "Überfällig"
|
||||
}
|
||||
},
|
||||
"attendance": {
|
||||
"title": "Anwesenheit",
|
||||
"present": "Anwesend",
|
||||
"absent": "Abwesend",
|
||||
"excused": "Entschuldigt",
|
||||
"arrivedLate": "Verspätet erschienen",
|
||||
"leftEarly": "Vorzeitig gegangen"
|
||||
},
|
||||
"protocol": {
|
||||
"generate": "Protokoll generieren",
|
||||
"preview": "Vorschau",
|
||||
"export": "Exportieren",
|
||||
"sign": "Unterschreiben",
|
||||
"finalize": "Fertigstellen",
|
||||
"formatPdf": "PDF",
|
||||
"formatDocx": "Word"
|
||||
}
|
||||
},
|
||||
"verband": {
|
||||
"title": "Verbandsverwaltung",
|
||||
"description": "Mitgliedsvereine, Kontaktpersonen, Beiträge und Statistiken verwalten",
|
||||
"dashboard": {
|
||||
"title": "Übersicht",
|
||||
"totalClubs": "Mitgliedsvereine gesamt",
|
||||
"totalMembers": "Mitglieder gesamt",
|
||||
"pendingDues": "Ausstehende Beiträge",
|
||||
"upcomingEvents": "Kommende Veranstaltungen"
|
||||
},
|
||||
"clubs": {
|
||||
"title": "Mitgliedsvereine",
|
||||
"newClub": "Neuer Verein",
|
||||
"editClub": "Verein bearbeiten",
|
||||
"deleteClub": "Verein löschen",
|
||||
"name": "Vereinsname",
|
||||
"shortName": "Kurzname",
|
||||
"number": "Vereinsnummer",
|
||||
"address": "Adresse",
|
||||
"postalCode": "PLZ",
|
||||
"city": "Ort",
|
||||
"phone": "Telefon",
|
||||
"email": "E-Mail",
|
||||
"website": "Webseite",
|
||||
"founded": "Gründungsjahr",
|
||||
"memberCount": "Mitgliederzahl",
|
||||
"joinedDate": "Beitrittsdatum",
|
||||
"status": "Status",
|
||||
"contacts": "Kontaktpersonen",
|
||||
"dues": "Beiträge",
|
||||
"notes": "Bemerkungen",
|
||||
"statuses": {
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv",
|
||||
"suspended": "Ruhend",
|
||||
"withdrawn": "Ausgetreten"
|
||||
}
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Kontaktpersonen",
|
||||
"newContact": "Neue Kontaktperson",
|
||||
"editContact": "Kontaktperson bearbeiten",
|
||||
"deleteContact": "Kontaktperson löschen",
|
||||
"firstName": "Vorname",
|
||||
"lastName": "Nachname",
|
||||
"role": "Funktion",
|
||||
"phone": "Telefon",
|
||||
"email": "E-Mail",
|
||||
"isPrimary": "Hauptkontakt",
|
||||
"roles": {
|
||||
"chairman": "Vorsitzende(r)",
|
||||
"viceChairman": "Stellv. Vorsitzende(r)",
|
||||
"treasurer": "Kassenwart(in)",
|
||||
"secretary": "Schriftführer(in)",
|
||||
"youthLeader": "Jugendleiter(in)",
|
||||
"boardMember": "Vorstandsmitglied",
|
||||
"delegate": "Delegierte(r)",
|
||||
"other": "Sonstige"
|
||||
}
|
||||
},
|
||||
"dues": {
|
||||
"title": "Beiträge",
|
||||
"newDue": "Neuer Beitrag",
|
||||
"editDue": "Beitrag bearbeiten",
|
||||
"year": "Jahr",
|
||||
"amount": "Betrag (EUR)",
|
||||
"dueDate": "Fälligkeitsdatum",
|
||||
"paidDate": "Bezahlt am",
|
||||
"status": "Status",
|
||||
"invoiceNumber": "Rechnungsnummer",
|
||||
"remarks": "Bemerkungen",
|
||||
"statuses": {
|
||||
"open": "Offen",
|
||||
"paid": "Bezahlt",
|
||||
"overdue": "Überfällig",
|
||||
"waived": "Erlassen",
|
||||
"partial": "Teilbezahlt"
|
||||
},
|
||||
"bulkCreate": "Beiträge generieren",
|
||||
"bulkCreateDescription": "Beiträge für alle aktiven Vereine erstellen",
|
||||
"totalOpen": "Gesamt offen",
|
||||
"totalPaid": "Gesamt bezahlt"
|
||||
},
|
||||
"statistics": {
|
||||
"title": "Statistiken",
|
||||
"membersByClub": "Mitglieder nach Verein",
|
||||
"membersTrend": "Mitgliederentwicklung",
|
||||
"duesOverview": "Beitragsübersicht",
|
||||
"clubsByRegion": "Vereine nach Region",
|
||||
"filterYear": "Jahr filtern"
|
||||
},
|
||||
"export": {
|
||||
"exportClubs": "Vereine exportieren",
|
||||
"exportContacts": "Kontaktpersonen exportieren",
|
||||
"exportDues": "Beiträge exportieren",
|
||||
"formatCsv": "CSV",
|
||||
"formatExcel": "Excel"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,10 @@
|
||||
"documents": "Dokumente",
|
||||
"newsletter": "Newsletter",
|
||||
"events": "Veranstaltungen",
|
||||
"siteBuilder": "Website"
|
||||
"siteBuilder": "Website",
|
||||
"fischerei": "Fischerei",
|
||||
"meetings": "Sitzungsprotokolle",
|
||||
"verband": "Verbandsverwaltung"
|
||||
},
|
||||
"roles": {
|
||||
"owner": {
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
"privacyPolicy": "Datenschutzerklärung",
|
||||
"privacyPolicyDescription": "Unsere Datenschutzerklärung und Datennutzung",
|
||||
"contactDescription": "Kontaktieren Sie uns bei Fragen oder Feedback",
|
||||
"contactHeading": "Senden Sie uns eine Nachricht",
|
||||
"contactSubheading": "Wir melden uns schnellstmöglich bei Ihnen",
|
||||
"contactHeading": "Wir sind für Sie da",
|
||||
"contactSubheading": "Persönliche Beratung, Testzugänge und individuelle Angebote — sprechen Sie uns an.",
|
||||
"contactName": "Ihr Name",
|
||||
"contactEmail": "Ihre E-Mail-Adresse",
|
||||
"contactMessage": "Ihre Nachricht",
|
||||
@@ -41,78 +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": "Die All-in-One-Verwaltungsplattform für Vereine, Clubs und Organisationen. Entwickelt von Com.BISS GmbH.",
|
||||
"copyright": "© Copyright {year} {product}. Alle Rechte vorbehalten.",
|
||||
"footerDescription": "Vereins- und Verbandsverwaltung aus Bayern — persönlich, zuverlässig und fair seit 2004. Entwickelt von Com.BISS GmbH, Schierling.",
|
||||
"copyright": "© 2004–{year} Com.BISS GmbH. 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.",
|
||||
"heroPill": "Seit 2004 — 22 Jahre Erfahrung",
|
||||
"heroTitle": "Vereinsverwaltung, die mitwächst",
|
||||
"heroSubtitle": "Von der Mitgliederverwaltung bis zum SEPA-Einzug — die Software, der Vereine und Verbände in ganz Bayern seit zwei Jahrzehnten vertrauen. 69.000 verwaltete Mitglieder, 90+ angebundene Vereine.",
|
||||
|
||||
"trustedBy": "Vertraut von Vereinen und Clubs in ganz Deutschland",
|
||||
"trustAssociations": "Vereine",
|
||||
"trustSchools": "Bildungseinrichtungen",
|
||||
"trustClubs": "Sport- & Angelvereine",
|
||||
"trustOrganizations": "Gemeinnützige Organisationen",
|
||||
"trustedBy": "Vertraut von Vereinen und Verbänden in ganz Bayern",
|
||||
"trustAssociations": "3 Bezirksfischereiverbände",
|
||||
"trustSchools": "VHS & Bildungseinrichtungen",
|
||||
"trustClubs": "90+ Vereine angebunden",
|
||||
"trustOrganizations": "Stadt Regensburg seit 2010",
|
||||
|
||||
"featuresHeading": "Alles, was Ihre Organisation braucht",
|
||||
"featuresSubheading": "Von der Mitgliederverwaltung bis zur Finanzbuchhaltung — alle Werkzeuge in einer modernen, benutzerfreundlichen Plattform.",
|
||||
"featuresHeading": "Alles, was Ihr Verein braucht",
|
||||
"featuresSubheading": "MYeasyCMS wurde speziell für die Bedürfnisse von Vereinen und Verbänden entwickelt — modular aufgebaut, webbasiert und über jeden Browser nutzbar.",
|
||||
"featuresLabel": "Kernmodule",
|
||||
|
||||
"featureMembersTitle": "Mitgliederverwaltung",
|
||||
"featureMembersDesc": "Verwalten Sie alle Mitglieder mit Abteilungen, Beitragsverfolgung, Mitgliedsausweisen, Anträgen und detaillierten Statistiken.",
|
||||
"featureMembersDesc": "Stammdaten, Kontaktinformationen, Mandate, Ehrungen, Historie und Notizen — alles an einem Ort. Mit Abteilungen, Beitragsverfolgung und Mitgliedsausweisen.",
|
||||
"featureCoursesTitle": "Kursverwaltung",
|
||||
"featureCoursesDesc": "Organisieren Sie Kurse mit Terminplanung, Dozentenzuweisung, Anwesenheitsverfolgung, Kategorien und Standorten.",
|
||||
"featureCoursesDesc": "Kurs-, Dozenten- und Teilnehmerverwaltung mit Kalender, Ferien- und Feiertagsberücksichtigung, Kostenkalkulation und Online-Anmeldung.",
|
||||
"featureBookingsTitle": "Raumbuchungen",
|
||||
"featureBookingsDesc": "Buchen Sie Räume und Ressourcen mit einem visuellen Kalender, verwalten Sie Gäste und prüfen Sie die Verfügbarkeit.",
|
||||
"featureBookingsDesc": "Reservierungsverwaltung mit Belegungsübersicht, Ferienberücksichtigung und Schlüsselmanagement für den Gebäudezugang.",
|
||||
"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.",
|
||||
"featureEventsDesc": "Planen und verwalten Sie Veranstaltungen mit Anmeldungen, Ferienpässen und Teilnehmerverfolgung — in Zusammenarbeit mit der Stadt Regensburg entwickelt.",
|
||||
"featureFinanceTitle": "Beiträge & SEPA",
|
||||
"featureFinanceDesc": "Beitrags- und Gebührenverwaltung, SEPA-Lastschriftmandate, XML-Export und Kontenführung nach Geschäftsjahren. Rechnungen und Zahlungen im Griff.",
|
||||
"featureNewsletterTitle": "E-Mail & Newsletter",
|
||||
"featureNewsletterDesc": "E-Mail-Kommunikation mit Mitgliedern und Newsletter-Versand 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.",
|
||||
"showcaseDescription": "Erhalten Sie einen vollständigen Überblick über Ihre Organisation — Mitglieder, Kurse, offene Rechnungen und Veranstaltungen — alles von einer zentralen Stelle aus. Schnellaktionen für die häufigsten Aufgaben.",
|
||||
|
||||
"additionalFeaturesHeading": "Und es gibt noch mehr",
|
||||
"additionalFeaturesSubheading": "Zusätzliche Werkzeuge, die jeden Aspekt der täglichen Arbeit Ihrer Organisation vereinfachen.",
|
||||
"additionalFeaturesSubheading": "Zusätzliche Werkzeuge, die jeden Aspekt der täglichen Vereinsarbeit 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.",
|
||||
"featureDocumentsTitle": "Dokumente & Ausweise",
|
||||
"featureDocumentsDesc": "Mitgliedsausweise, Rechnungen, Etiketten, Berichte, Briefe und Zertifikate — aus Vorlagen generiert, exportierbar als PDF und Excel.",
|
||||
"featureSiteBuilderTitle": "Vereins-Website",
|
||||
"featureSiteBuilderDesc": "Erstellen Sie die öffentliche Website Ihres Vereins ohne Programmierkenntnisse — mit Drag-and-Drop-Editor, Veranstaltungen und Kursangebot direkt aus dem CMS.",
|
||||
"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.",
|
||||
"featureModulesDesc": "Erweitern Sie die Plattform mit eigenen Datenmodulen für Gewässer, Fangbücher, Arbeitsdienste oder beliebige weitere Vereinsdaten.",
|
||||
|
||||
"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.",
|
||||
"whyChooseHeading": "Warum Vereine Com.BISS vertrauen",
|
||||
"whyChooseDescription": "2004 schlossen sich fünf Frauen zusammen und gründeten eine Web-Agentur. Seitdem arbeiten wir Hand in Hand mit unseren Kunden — persönlich, fair und zuverlässig. Geschäftsführerinnen: Brit Schiergl und Elisabeth Zehetbauer.",
|
||||
"whyResponsiveTitle": "Persönlich",
|
||||
"whyResponsiveDesc": "Wir kennen unsere Kunden und deren Anforderungen. Kein anonymes Ticketsystem — direkter Kontakt mit den Menschen, die Ihre Software entwickeln.",
|
||||
"whySecureTitle": "Zuverlässig",
|
||||
"whySecureDesc": "22 Jahre Kundenbeziehungen sprechen für sich. Wir unterstützen Sie auch außerhalb der üblichen Arbeitszeiten — schnell und unkompliziert.",
|
||||
"whySupportTitle": "Fair",
|
||||
"whySupportDesc": "Faire Preise, schnelle und flexible Umsetzung der Kundenwünsche. Neuentwicklungen und Änderungen zu festen Preisen — keine Überraschungen.",
|
||||
"whyGdprTitle": "100% Server in Deutschland",
|
||||
"whyGdprDesc": "Sitz in Schierling bei Regensburg. Server in Deutschland. DSGVO-konform. Ihre Daten bleiben hier.",
|
||||
|
||||
"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.",
|
||||
"howItWorksSubheading": "Die Einrichtung Ihres Vereins auf MYeasyCMS dauert nur wenige Minuten.",
|
||||
"howStep1Title": "Testzugang anfragen",
|
||||
"howStep1Desc": "Fordern Sie einen kostenlosen Testzugang an — wir richten alles für Sie ein und führen Sie persönlich durch die Plattform.",
|
||||
"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.",
|
||||
"howStep2Desc": "Aktivieren Sie die benötigten Module — Mitglieder, Beiträge, Gewässer, SEPA — und passen Sie diese an Ihren Verein an.",
|
||||
"howStep3Title": "Vereinsverwaltung starten",
|
||||
"howStep3Desc": "Importieren Sie Ihre bestehenden Mitgliederlisten und legen Sie sofort los — mit persönlicher Unterstützung durch unser Team.",
|
||||
|
||||
"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.",
|
||||
"pricingPillLabel": "Faire Preise",
|
||||
"pricingPillText": "Keine Begrenzung der Benutzeranzahl",
|
||||
"pricingHeading": "Faire Preise nach Vereinsgröße",
|
||||
"pricingSubheading": "Alle Funktionen inklusive. Keine versteckten Kosten. Persönliche Einrichtung bei jedem Tarif.",
|
||||
|
||||
"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."
|
||||
"ctaHeading": "Bereit für eine Verwaltung, die einfach funktioniert?",
|
||||
"ctaDescription": "Fordern Sie einen kostenlosen Testzugang an — oder lassen Sie sich persönlich beraten. Telefon: 09451 9499-09.",
|
||||
"ctaButtonPrimary": "Kostenlosen Testzugang anfragen",
|
||||
"ctaButtonSecondary": "Persönlich beraten lassen",
|
||||
"ctaNote": "Kein Risiko. Persönliche Einrichtung inklusive. DSGVO-konform."
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user