Compare commits
4 Commits
9f83b5cc75
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cbe6652a1 | ||
|
|
ad01ecb8b9 | ||
|
|
7cfd88f1c3 | ||
|
|
1215e351c1 |
@@ -1 +1 @@
|
||||
[]
|
||||
[]
|
||||
@@ -4,13 +4,11 @@ import {
|
||||
ArrowLeft,
|
||||
BedDouble,
|
||||
CalendarDays,
|
||||
LogIn,
|
||||
LogOut,
|
||||
XCircle,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { BookingStatusActions } from '@kit/booking-management/components';
|
||||
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
@@ -288,41 +286,10 @@ export default async function BookingDetailPage({ params }: PageProps) {
|
||||
<CardDescription>{t('detail.changeStatus')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{(status === 'pending' || status === 'confirmed') && (
|
||||
<Button variant="default">
|
||||
<LogIn className="mr-2 h-4 w-4" />
|
||||
{t('detail.checkIn')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status === 'checked_in' && (
|
||||
<Button variant="default">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t('detail.checkOut')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status !== 'cancelled' &&
|
||||
status !== 'checked_out' &&
|
||||
status !== 'no_show' && (
|
||||
<Button variant="destructive">
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
{t('detail.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status === 'cancelled' || status === 'checked_out' ? (
|
||||
<p className="text-muted-foreground py-2 text-sm">
|
||||
{t('detail.noMoreActions', {
|
||||
statusLabel:
|
||||
status === 'cancelled'
|
||||
? t('detail.cancelledStatus')
|
||||
: t('detail.completedStatus'),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<BookingStatusActions
|
||||
bookingId={bookingId}
|
||||
status={status}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
@@ -51,8 +52,9 @@ function isDateInRange(
|
||||
return date >= checkIn && date < checkOut;
|
||||
}
|
||||
|
||||
export default async function BookingCalendarPage({ params }: PageProps) {
|
||||
export default async function BookingCalendarPage({ params, searchParams }: PageProps) {
|
||||
const { account } = await params;
|
||||
const search = await searchParams;
|
||||
const t = await getTranslations('bookings');
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
@@ -73,8 +75,15 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
||||
const api = createBookingManagementApi(client);
|
||||
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
const year = Number(search.year) || now.getFullYear();
|
||||
const month = search.month != null ? Number(search.month) - 1 : now.getMonth();
|
||||
|
||||
// Compute prev/next month for navigation links
|
||||
const prevMonth = month === 0 ? 12 : month;
|
||||
const prevYear = month === 0 ? year - 1 : year;
|
||||
const nextMonth = month === 11 ? 1 : month + 2;
|
||||
const nextYear = month === 11 ? year + 1 : year;
|
||||
|
||||
const daysInMonth = getDaysInMonth(year, month);
|
||||
const firstWeekday = getFirstWeekday(year, month);
|
||||
|
||||
@@ -160,10 +169,12 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled
|
||||
asChild
|
||||
aria-label={t('calendar.previousMonth')}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||
<Link href={`/home/${account}/bookings/calendar?year=${prevYear}&month=${prevMonth}`}>
|
||||
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
<CardTitle>
|
||||
{MONTH_NAMES[month]} {year}
|
||||
@@ -171,10 +182,12 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled
|
||||
asChild
|
||||
aria-label={t('calendar.nextMonth')}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||
<Link href={`/home/${account}/bookings/calendar?year=${nextYear}&month=${nextMonth}`}>
|
||||
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { UserCircle, Plus } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
import { CreateGuestDialog } from '@kit/booking-management/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
@@ -41,10 +42,7 @@ export default async function GuestsPage({ params }: PageProps) {
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-muted-foreground">{t('guests.manage')}</p>
|
||||
<Button data-test="guests-new-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('guests.newGuest')}
|
||||
</Button>
|
||||
<CreateGuestDialog accountId={acct.id} />
|
||||
</div>
|
||||
|
||||
{guests.length === 0 ? (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BedDouble, Plus } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
import { CreateRoomDialog } from '@kit/booking-management/components';
|
||||
import { formatCurrencyAmount } from '@kit/shared/dates';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
@@ -43,10 +44,7 @@ export default async function RoomsPage({ params }: PageProps) {
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-muted-foreground">{t('rooms.manage')}</p>
|
||||
<Button data-test="rooms-new-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('rooms.newRoom')}
|
||||
</Button>
|
||||
<CreateRoomDialog accountId={acct.id} />
|
||||
</div>
|
||||
|
||||
{rooms.length === 0 ? (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Plus, Users } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
import { EnrollParticipantDialog } from '@kit/course-management/components';
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
@@ -56,10 +57,7 @@ export default async function ParticipantsPage({ params }: PageProps) {
|
||||
{participants.length} {t('participants.title')}
|
||||
</p>
|
||||
</div>
|
||||
<Button data-test="participants-add-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('participants.add')}
|
||||
</Button>
|
||||
<EnrollParticipantDialog courseId={courseId} />
|
||||
</div>
|
||||
|
||||
{participants.length === 0 ? (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { FolderTree } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
import { DeleteRefDataButton } from '@kit/course-management/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
@@ -67,6 +68,7 @@ export default async function CategoriesPage({ params }: PageProps) {
|
||||
<th scope="col" className="p-3 text-left font-medium">
|
||||
{t('common.parent')}
|
||||
</th>
|
||||
<th scope="col" className="w-16 p-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -80,6 +82,13 @@ export default async function CategoriesPage({ params }: PageProps) {
|
||||
{String(cat.description ?? '—')}
|
||||
</td>
|
||||
<td className="p-3">{String(cat.parent_id ?? '—')}</td>
|
||||
<td className="p-3 text-right">
|
||||
<DeleteRefDataButton
|
||||
id={String(cat.id)}
|
||||
type="category"
|
||||
itemName={String(cat.name)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { GraduationCap } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
import { DeleteRefDataButton } from '@kit/course-management/components';
|
||||
import { formatCurrencyAmount } from '@kit/shared/dates';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
@@ -74,6 +75,7 @@ export default async function InstructorsPage({ params }: PageProps) {
|
||||
<th scope="col" className="p-3 text-right font-medium">
|
||||
{t('instructors.hourlyRate')}
|
||||
</th>
|
||||
<th scope="col" className="w-16 p-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -96,6 +98,13 @@ export default async function InstructorsPage({ params }: PageProps) {
|
||||
? formatCurrencyAmount(inst.hourly_rate as number)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<DeleteRefDataButton
|
||||
id={String(inst.id)}
|
||||
type="instructor"
|
||||
itemName={`${inst.first_name} ${inst.last_name}`}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { MapPin } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
import { DeleteRefDataButton } from '@kit/course-management/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
@@ -70,6 +71,7 @@ export default async function LocationsPage({ params }: PageProps) {
|
||||
<th scope="col" className="p-3 text-right font-medium">
|
||||
{t('list.capacity')}
|
||||
</th>
|
||||
<th scope="col" className="w-16 p-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -89,6 +91,13 @@ export default async function LocationsPage({ params }: PageProps) {
|
||||
<td className="p-3 text-right">
|
||||
{String(loc.capacity ?? '—')}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<DeleteRefDataButton
|
||||
id={String(loc.id)}
|
||||
type="location"
|
||||
itemName={String(loc.name)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -26,13 +26,19 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
|
||||
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
// Document templates are stored locally for now — placeholder for future DB integration
|
||||
const templates: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
}> = [];
|
||||
// Fetch document templates from DB
|
||||
const { data: templates } = await client
|
||||
.from('document_templates')
|
||||
.select('id, name, template_type, description')
|
||||
.eq('account_id', acct.id)
|
||||
.order('name');
|
||||
|
||||
const templatesList = (templates ?? []).map((t: any) => ({
|
||||
id: String(t.id),
|
||||
name: String(t.name),
|
||||
type: String(t.template_type ?? '—'),
|
||||
description: String(t.description ?? ''),
|
||||
}));
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={t('templates.title')}>
|
||||
@@ -50,7 +56,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
|
||||
</div>
|
||||
|
||||
{/* Table or Empty State */}
|
||||
{templates.length === 0 ? (
|
||||
{templatesList.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FileText className="h-8 w-8" />}
|
||||
title={t('templates.noTemplates')}
|
||||
@@ -61,7 +67,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{t('templates.allTemplates', { count: templates.length })}
|
||||
{t('templates.allTemplates', { count: templatesList.length })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -81,7 +87,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templates.map((template) => (
|
||||
{templatesList.map((template) => (
|
||||
<tr
|
||||
key={template.id}
|
||||
className="hover:bg-muted/30 border-b"
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { createEventManagementApi } from '@kit/event-management/api';
|
||||
import { EventRegistrationDialog } from '@kit/event-management/components';
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
@@ -73,10 +74,7 @@ export default async function EventDetailPage({ params }: PageProps) {
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t('register')}
|
||||
</Button>
|
||||
<EventRegistrationDialog eventId={eventId} eventName={String((event as any).name ?? '')} />
|
||||
</div>
|
||||
|
||||
{/* Detail Cards */}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Ticket, Plus } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { createEventManagementApi } from '@kit/event-management/api';
|
||||
import { CreateHolidayPassDialog } from '@kit/event-management/components';
|
||||
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
@@ -40,10 +41,7 @@ export default async function HolidayPassesPage({ params }: PageProps) {
|
||||
{t('holidayPassesDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('newHolidayPass')}
|
||||
</Button>
|
||||
<CreateHolidayPassDialog accountId={acct.id} />
|
||||
</div>
|
||||
|
||||
{passes.length === 0 ? (
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft, Download } from 'lucide-react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { createFinanceApi } from '@kit/finance/api';
|
||||
import { SepaBatchActions } from '@kit/finance/components';
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
@@ -124,10 +124,12 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
|
||||
</dl>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button disabled variant="outline">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('sepa.downloadXml')}
|
||||
</Button>
|
||||
<SepaBatchActions
|
||||
batchId={batchId}
|
||||
accountId={acct.id}
|
||||
batchStatus={status}
|
||||
itemCount={items.length}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -108,7 +108,7 @@ export default async function SepaPage({ params }: PageProps) {
|
||||
{batches.map((batch: Record<string, unknown>) => (
|
||||
<tr
|
||||
key={String(batch.id)}
|
||||
className="hover:bg-muted/30 border-b"
|
||||
className="hover:bg-muted/30 cursor-pointer border-b"
|
||||
>
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
@@ -131,7 +131,7 @@ export default async function SepaPage({ params }: PageProps) {
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/home/${account}/finance/sepa/${String(batch.id)}`}
|
||||
className="hover:underline"
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{String(batch.description ?? '—')}
|
||||
</Link>
|
||||
|
||||
@@ -24,6 +24,29 @@ export default async function StatisticsPage({ params }: Props) {
|
||||
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
// Fetch actual statistics from existing tables
|
||||
const [watersResult, speciesResult, stockingResult, catchBooksResult, leasesResult, permitsResult] = await Promise.allSettled([
|
||||
client.from('waters').select('id', { count: 'exact' }).eq('account_id', acct.id),
|
||||
client.from('fish_species').select('id', { count: 'exact' }).eq('account_id', acct.id),
|
||||
client.from('fish_stocking').select('id, quantity, cost_total', { count: 'exact' }).eq('account_id', acct.id),
|
||||
client.from('catch_books').select('id, status', { count: 'exact' }).eq('account_id', acct.id),
|
||||
client.from('fishing_leases').select('id', { count: 'exact' }).eq('account_id', acct.id).eq('status', 'active'),
|
||||
client.from('fishing_permits').select('id', { count: 'exact' }).eq('account_id', acct.id),
|
||||
]);
|
||||
|
||||
const waterCount = watersResult.status === 'fulfilled' ? (watersResult.value.count ?? 0) : 0;
|
||||
const speciesCount = speciesResult.status === 'fulfilled' ? (speciesResult.value.count ?? 0) : 0;
|
||||
const stockingData = stockingResult.status === 'fulfilled' ? (stockingResult.value.data ?? []) : [];
|
||||
const stockingCount = stockingData.length;
|
||||
const stockingCost = stockingData.reduce((sum: number, s: any) => sum + (Number(s.cost_total) || 0), 0);
|
||||
const catchBookCount = catchBooksResult.status === 'fulfilled' ? (catchBooksResult.value.count ?? 0) : 0;
|
||||
const catchBookData = catchBooksResult.status === 'fulfilled' ? (catchBooksResult.value.data ?? []) : [];
|
||||
const pendingCatchBooks = catchBookData.filter((cb: any) => cb.status === 'submitted').length;
|
||||
const leaseCount = leasesResult.status === 'fulfilled' ? (leasesResult.value.count ?? 0) : 0;
|
||||
const permitCount = permitsResult.status === 'fulfilled' ? (permitsResult.value.count ?? 0) : 0;
|
||||
|
||||
const formatCurrency = (v: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={t('pages.statisticsTitle')}>
|
||||
<FischereiTabNavigation account={account} activeTab="statistics" />
|
||||
@@ -33,22 +56,27 @@ export default async function StatisticsPage({ params }: Props) {
|
||||
Fangstatistiken und Auswertungen
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card><CardContent className="p-6"><p className="text-muted-foreground text-sm">Gewässer</p><p className="text-2xl font-bold">{waterCount}</p></CardContent></Card>
|
||||
<Card><CardContent className="p-6"><p className="text-muted-foreground text-sm">Fischarten</p><p className="text-2xl font-bold">{speciesCount}</p></CardContent></Card>
|
||||
<Card><CardContent className="p-6"><p className="text-muted-foreground text-sm">Besatzaktionen</p><p className="text-2xl font-bold">{stockingCount}</p><p className="text-muted-foreground text-xs">{formatCurrency(stockingCost)} Gesamtkosten</p></CardContent></Card>
|
||||
<Card><CardContent className="p-6"><p className="text-muted-foreground text-sm">Fangbücher</p><p className="text-2xl font-bold">{catchBookCount}</p>{pendingCatchBooks > 0 && <p className="text-xs text-amber-600">{pendingCatchBooks} zur Prüfung</p>}</CardContent></Card>
|
||||
<Card><CardContent className="p-6"><p className="text-muted-foreground text-sm">Aktive Pachten</p><p className="text-2xl font-bold">{leaseCount}</p></CardContent></Card>
|
||||
<Card><CardContent className="p-6"><p className="text-muted-foreground text-sm">Erlaubnisscheine</p><p className="text-2xl font-bold">{permitCount}</p></CardContent></Card>
|
||||
</div>
|
||||
|
||||
{waterCount === 0 && speciesCount === 0 && (
|
||||
<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>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
|
||||
<h3 className="text-lg font-semibold">Noch keine Daten vorhanden</h3>
|
||||
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
|
||||
Sobald Fangbücher eingereicht und geprüft werden, erscheinen
|
||||
hier Statistiken und Auswertungen.
|
||||
Sobald Gewässer, Fischarten und Fangbücher angelegt werden, erscheinen hier detaillierte Statistiken.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
|
||||
@@ -35,7 +35,11 @@ export default async function MemberStatisticsPage({ params }: PageProps) {
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const { query } = createMemberServices(client);
|
||||
const stats = await query.getStatistics(acct.id);
|
||||
const statsRaw = await query.getStatistics(acct.id);
|
||||
|
||||
// Compute total from individual status counts
|
||||
const total = Object.values(statsRaw).reduce((a, b) => a + b, 0);
|
||||
const stats = { ...statsRaw, total };
|
||||
|
||||
const statusChartData = [
|
||||
{ name: t('status.active'), value: stats.active ?? 0 },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { FileText, Plus } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { createNewsletterApi } from '@kit/newsletter/api';
|
||||
import { CreateTemplateDialog } from '@kit/newsletter/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
@@ -41,10 +42,7 @@ export default async function NewsletterTemplatesPage({ params }: PageProps) {
|
||||
<p className="text-muted-foreground">{t('templates.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<Button data-test="newsletter-templates-new-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('templates.newTemplate')}
|
||||
</Button>
|
||||
<CreateTemplateDialog accountId={acct.id} />
|
||||
</div>
|
||||
|
||||
{/* Table or Empty State */}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Plus } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
@@ -45,9 +47,11 @@ export default async function PostsManagerPage({ params }: Props) {
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<Button data-test="site-new-post-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('posts.newPost')}
|
||||
<Button data-test="site-new-post-btn" asChild>
|
||||
<Link href={`/home/${account}/site-builder/posts/new`}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('posts.newPost')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{posts.length === 0 ? (
|
||||
|
||||
@@ -22,15 +22,60 @@ const PLACEHOLDER_DATA = [
|
||||
{ year: '2025', vereine: 19, mitglieder: 1200 },
|
||||
];
|
||||
|
||||
export default function StatisticsContent() {
|
||||
export default function StatisticsContent({
|
||||
activeClubs = 0,
|
||||
totalClubs = 0,
|
||||
totalMembers = 0,
|
||||
openFees = 0,
|
||||
}: {
|
||||
activeClubs?: number;
|
||||
totalClubs?: number;
|
||||
totalMembers?: number;
|
||||
openFees?: number;
|
||||
}) {
|
||||
const formatCurrency = (v: number) =>
|
||||
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-muted-foreground">
|
||||
Entwicklung der Mitgliedsvereine und Gesamtmitglieder im Zeitverlauf
|
||||
Aktuelle Kennzahlen des Verbands
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-muted-foreground text-sm">Aktive Vereine</p>
|
||||
<p className="text-2xl font-bold">{activeClubs}</p>
|
||||
<p className="text-muted-foreground text-xs">{totalClubs} gesamt</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-muted-foreground text-sm">Gesamtmitglieder</p>
|
||||
<p className="text-2xl font-bold">{totalMembers}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-muted-foreground text-sm">∅ Mitglieder/Verein</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{activeClubs > 0 ? Math.round(totalMembers / activeClubs) : 0}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-muted-foreground text-sm">Offene Beiträge</p>
|
||||
<p className="text-2xl font-bold">{formatCurrency(openFees)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts (keep existing placeholder data as trend visualization) */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { VerbandTabNavigation } from '@kit/verbandsverwaltung/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
import StatisticsContent from './_components/statistics-content';
|
||||
@@ -13,11 +15,50 @@ interface Props {
|
||||
export default async function StatisticsPage({ params }: Props) {
|
||||
const { account } = await params;
|
||||
const t = await getTranslations('verband');
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
// Fetch real verband stats
|
||||
const [clubsResult, membersResult, feesResult] = await Promise.allSettled([
|
||||
client
|
||||
.from('member_clubs')
|
||||
.select('id, status, member_count', { count: 'exact' })
|
||||
.eq('account_id', acct.id),
|
||||
client
|
||||
.from('members')
|
||||
.select('id', { count: 'exact' })
|
||||
.eq('account_id', acct.id)
|
||||
.eq('status', 'active'),
|
||||
(client.from as any)('club_fees')
|
||||
.select('amount, status')
|
||||
.eq('account_id', acct.id),
|
||||
]);
|
||||
|
||||
const clubs = clubsResult.status === 'fulfilled' ? (clubsResult.value.data ?? []) : [];
|
||||
const activeClubs = clubs.filter((c: any) => c.status !== 'archived').length;
|
||||
const totalMembers = clubsResult.status === 'fulfilled'
|
||||
? clubs.reduce((sum: number, c: any) => sum + (Number(c.member_count) || 0), 0)
|
||||
: 0;
|
||||
const directMembers = membersResult.status === 'fulfilled' ? (membersResult.value.count ?? 0) : 0;
|
||||
const fees = feesResult.status === 'fulfilled' ? (feesResult.value.data ?? []) : [];
|
||||
const openFees = fees.filter((f: any) => f.status !== 'paid').reduce((s: number, f: any) => s + (Number(f.amount) || 0), 0);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={t('pages.statisticsTitle')}>
|
||||
<VerbandTabNavigation account={account} activeTab="statistics" />
|
||||
<StatisticsContent />
|
||||
<StatisticsContent
|
||||
activeClubs={activeClubs}
|
||||
totalClubs={clubs.length}
|
||||
totalMembers={totalMembers || directMembers}
|
||||
openFees={openFees}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,7 +139,58 @@
|
||||
"administration": "Administration",
|
||||
"accountSettings": "Kontoeinstellungen",
|
||||
"application": "Anwendung",
|
||||
"home": "Startseite"
|
||||
"home": "Startseite",
|
||||
"courses": "Kurse",
|
||||
"calendar": "Kalender",
|
||||
"instructors": "Kursleiter",
|
||||
"locations": "Standorte",
|
||||
"categories": "Kategorien",
|
||||
"statistics": "Statistiken",
|
||||
"events": "Veranstaltungen",
|
||||
"registrations": "Anmeldungen",
|
||||
"holiday passes": "Ferienpässe",
|
||||
"bookings": "Buchungen",
|
||||
"rooms": "Zimmer",
|
||||
"guests": "Gäste",
|
||||
"finance": "Finanzen",
|
||||
"invoices": "Rechnungen",
|
||||
"sepa": "SEPA-Einzüge",
|
||||
"payments": "Zahlungen",
|
||||
"documents": "Dokumente",
|
||||
"generate": "Generieren",
|
||||
"templates": "Vorlagen",
|
||||
"newsletter": "Newsletter",
|
||||
"new": "Neu",
|
||||
"edit": "Bearbeiten",
|
||||
"members": "Mitglieder",
|
||||
"members cms": "Vereinsmitglieder",
|
||||
"site builder": "Website",
|
||||
"posts": "Beiträge",
|
||||
"fischerei": "Fischerei",
|
||||
"waters": "Gewässer",
|
||||
"species": "Fischarten",
|
||||
"stocking": "Besatz",
|
||||
"leases": "Pachten",
|
||||
"catch books": "Fangbücher",
|
||||
"permits": "Erlaubnisscheine",
|
||||
"competitions": "Wettbewerbe",
|
||||
"meetings": "Sitzungen",
|
||||
"protocols": "Protokolle",
|
||||
"tasks": "Aufgaben",
|
||||
"verband": "Verband",
|
||||
"clubs": "Vereine",
|
||||
"hierarchy": "Organisationsstruktur",
|
||||
"reporting": "Berichte",
|
||||
"modules": "Module",
|
||||
"import": "Import",
|
||||
"applications": "Aufnahmeanträge",
|
||||
"departments": "Abteilungen",
|
||||
"dues": "Beiträge",
|
||||
"tags": "Tags",
|
||||
"cards": "Mitgliedsausweise",
|
||||
"invitations": "Einladungen",
|
||||
"attendance": "Anwesenheit",
|
||||
"participants": "Teilnehmer"
|
||||
},
|
||||
"roles": {
|
||||
"owner": {
|
||||
@@ -223,4 +274,4 @@
|
||||
"action": "Zum Dashboard"
|
||||
},
|
||||
"confirm": "Bestätigen"
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,58 @@
|
||||
"associationTemplates": "Shared Templates",
|
||||
"administration": "Administration",
|
||||
"accountSettings": "Account Settings",
|
||||
"application": "Application"
|
||||
"application": "Application",
|
||||
"courses": "Courses",
|
||||
"calendar": "Calendar",
|
||||
"instructors": "Instructors",
|
||||
"locations": "Locations",
|
||||
"categories": "Categories",
|
||||
"statistics": "Statistics",
|
||||
"events": "Events",
|
||||
"registrations": "Registrations",
|
||||
"holiday passes": "Holiday Passes",
|
||||
"bookings": "Bookings",
|
||||
"rooms": "Rooms",
|
||||
"guests": "Guests",
|
||||
"finance": "Finance",
|
||||
"invoices": "Invoices",
|
||||
"sepa": "SEPA",
|
||||
"payments": "Payments",
|
||||
"documents": "Documents",
|
||||
"generate": "Generate",
|
||||
"templates": "Templates",
|
||||
"newsletter": "Newsletter",
|
||||
"new": "New",
|
||||
"edit": "Edit",
|
||||
"members": "Members",
|
||||
"members cms": "Members",
|
||||
"site builder": "Site Builder",
|
||||
"posts": "Posts",
|
||||
"fischerei": "Fisheries",
|
||||
"waters": "Waters",
|
||||
"species": "Species",
|
||||
"stocking": "Stocking",
|
||||
"leases": "Leases",
|
||||
"catch books": "Catch Books",
|
||||
"permits": "Permits",
|
||||
"competitions": "Competitions",
|
||||
"meetings": "Meetings",
|
||||
"protocols": "Protocols",
|
||||
"tasks": "Tasks",
|
||||
"verband": "Federation",
|
||||
"clubs": "Clubs",
|
||||
"hierarchy": "Hierarchy",
|
||||
"reporting": "Reporting",
|
||||
"modules": "Modules",
|
||||
"import": "Import",
|
||||
"applications": "Applications",
|
||||
"departments": "Departments",
|
||||
"dues": "Dues",
|
||||
"tags": "Tags",
|
||||
"cards": "Cards",
|
||||
"invitations": "Invitations",
|
||||
"attendance": "Attendance",
|
||||
"participants": "Participants"
|
||||
},
|
||||
"roles": {
|
||||
"owner": {
|
||||
@@ -223,4 +274,4 @@
|
||||
"action": "Go to Dashboard"
|
||||
},
|
||||
"confirm": "Confirm"
|
||||
}
|
||||
}
|
||||
@@ -35,5 +35,8 @@
|
||||
"react": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { LogIn, LogOut, XCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { updateBookingStatus } from '../server/actions/booking-actions';
|
||||
|
||||
interface BookingStatusActionsProps {
|
||||
bookingId: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function BookingStatusActions({
|
||||
bookingId,
|
||||
status,
|
||||
}: BookingStatusActionsProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const action = useAction(updateBookingStatus, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Status aktualisiert');
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(data?.error ?? 'Fehler beim Aktualisieren');
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Fehler beim Aktualisieren des Status');
|
||||
},
|
||||
});
|
||||
|
||||
const execute = (newStatus: string) =>
|
||||
action.execute({ bookingId, status: newStatus as any });
|
||||
|
||||
if (status === 'cancelled' || status === 'checked_out') {
|
||||
return (
|
||||
<p className="text-muted-foreground py-2 text-sm">
|
||||
{status === 'cancelled'
|
||||
? 'Diese Buchung wurde storniert.'
|
||||
: 'Diese Buchung ist abgeschlossen.'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{(status === 'pending' || status === 'confirmed') && (
|
||||
<Button
|
||||
onClick={() => execute('checked_in')}
|
||||
disabled={action.isPending}
|
||||
>
|
||||
{action.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogIn className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Einchecken
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status === 'checked_in' && (
|
||||
<Button
|
||||
onClick={() => execute('checked_out')}
|
||||
disabled={action.isPending}
|
||||
>
|
||||
{action.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Auschecken
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status !== 'cancelled' &&
|
||||
status !== 'checked_out' &&
|
||||
status !== 'no_show' && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => execute('cancelled')}
|
||||
disabled={action.isPending}
|
||||
>
|
||||
{action.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Stornieren
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Plus, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { createGuest } from '../server/actions/booking-actions';
|
||||
|
||||
interface CreateGuestDialogProps {
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
export function CreateGuestDialog({ accountId }: CreateGuestDialogProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({ firstName: '', lastName: '', email: '', phone: '' });
|
||||
|
||||
const action = useAction(createGuest, {
|
||||
onSuccess: () => {
|
||||
toast.success('Gast erstellt');
|
||||
setOpen(false);
|
||||
setForm({ firstName: '', lastName: '', email: '', phone: '' });
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => toast.error('Fehler beim Erstellen'),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />Neuer Gast</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Gast anlegen</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Vorname *</Label>
|
||||
<Input value={form.firstName} onChange={(e) => setForm(s => ({ ...s, firstName: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Nachname *</Label>
|
||||
<Input value={form.lastName} onChange={(e) => setForm(s => ({ ...s, lastName: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>E-Mail</Label>
|
||||
<Input type="email" value={form.email} onChange={(e) => setForm(s => ({ ...s, email: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Telefon</Label>
|
||||
<Input value={form.phone} onChange={(e) => setForm(s => ({ ...s, phone: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||
<Button onClick={() => action.execute({ accountId, firstName: form.firstName, lastName: form.lastName, email: form.email || undefined, phone: form.phone || undefined })} disabled={action.isPending || !form.firstName || !form.lastName}>
|
||||
{action.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Erstellen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Plus, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { createRoom } from '../server/actions/booking-actions';
|
||||
|
||||
interface CreateRoomDialogProps {
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
export function CreateRoomDialog({ accountId }: CreateRoomDialogProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
roomNumber: '',
|
||||
name: '',
|
||||
roomType: 'single',
|
||||
capacity: '2',
|
||||
pricePerNight: '0',
|
||||
});
|
||||
|
||||
const action = useAction(createRoom, {
|
||||
onSuccess: () => {
|
||||
toast.success('Zimmer erstellt');
|
||||
setOpen(false);
|
||||
setForm({ roomNumber: '', name: '', roomType: 'single', capacity: '2', pricePerNight: '0' });
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => toast.error('Fehler beim Erstellen'),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />Neues Zimmer</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Zimmer anlegen</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Zimmernummer *</Label>
|
||||
<Input placeholder="z.B. 101" value={form.roomNumber} onChange={(e) => setForm(s => ({ ...s, roomNumber: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Bezeichnung</Label>
|
||||
<Input placeholder="z.B. Doppelzimmer Süd" value={form.name} onChange={(e) => setForm(s => ({ ...s, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Kapazität</Label>
|
||||
<Input type="number" min="1" value={form.capacity} onChange={(e) => setForm(s => ({ ...s, capacity: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Preis/Nacht (€)</Label>
|
||||
<Input type="number" step="0.01" min="0" value={form.pricePerNight} onChange={(e) => setForm(s => ({ ...s, pricePerNight: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||
<Button onClick={() => action.execute({ accountId, roomNumber: form.roomNumber, name: form.name || undefined, roomType: form.roomType as any, capacity: Number(form.capacity) || 2, pricePerNight: Number(form.pricePerNight) || 0 })} disabled={action.isPending || !form.roomNumber}>
|
||||
{action.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Erstellen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,4 @@
|
||||
export { CreateBookingForm } from './create-booking-form';
|
||||
export { BookingStatusActions } from './booking-status-actions';
|
||||
export { CreateRoomDialog } from './create-room-dialog';
|
||||
export { CreateGuestDialog } from './create-guest-dialog';
|
||||
|
||||
@@ -35,5 +35,8 @@
|
||||
"react": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Loader2, Trash2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@kit/ui/alert-dialog';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import {
|
||||
deleteCategory,
|
||||
deleteInstructor,
|
||||
deleteLocation,
|
||||
} from '../server/actions/course-actions';
|
||||
|
||||
type RefDataType = 'category' | 'instructor' | 'location';
|
||||
|
||||
const actions = {
|
||||
category: deleteCategory,
|
||||
instructor: deleteInstructor,
|
||||
location: deleteLocation,
|
||||
};
|
||||
|
||||
const labels: Record<RefDataType, { name: string; confirm: string }> = {
|
||||
category: {
|
||||
name: 'Kategorie',
|
||||
confirm: 'Möchten Sie diese Kategorie wirklich löschen?',
|
||||
},
|
||||
instructor: {
|
||||
name: 'Kursleiter',
|
||||
confirm: 'Möchten Sie diesen Kursleiter wirklich löschen?',
|
||||
},
|
||||
location: {
|
||||
name: 'Standort',
|
||||
confirm: 'Möchten Sie diesen Standort wirklich löschen?',
|
||||
},
|
||||
};
|
||||
|
||||
interface DeleteRefDataButtonProps {
|
||||
id: string;
|
||||
type: RefDataType;
|
||||
itemName: string;
|
||||
}
|
||||
|
||||
export function DeleteRefDataButton({
|
||||
id,
|
||||
type,
|
||||
itemName,
|
||||
}: DeleteRefDataButtonProps) {
|
||||
const router = useRouter();
|
||||
const label = labels[type];
|
||||
const action = useAction(actions[type], {
|
||||
onSuccess: () => {
|
||||
toast.success(`${label.name} „${itemName}" gelöscht`);
|
||||
router.refresh();
|
||||
},
|
||||
onError: () =>
|
||||
toast.error(`Fehler beim Löschen`, {
|
||||
description:
|
||||
'Möglicherweise wird der Eintrag noch von Kursen verwendet.',
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10 h-8 w-8"
|
||||
aria-label={`${label.name} löschen`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{label.name} löschen</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{label.confirm} „<strong>{itemName}</strong>" wird unwiderruflich
|
||||
entfernt.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => action.execute({ id })}
|
||||
disabled={action.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{action.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Löschen
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Plus, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { enrollParticipant } from '../server/actions/course-actions';
|
||||
|
||||
interface EnrollParticipantDialogProps {
|
||||
courseId: string;
|
||||
}
|
||||
|
||||
export function EnrollParticipantDialog({ courseId }: EnrollParticipantDialogProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({ firstName: '', lastName: '', email: '', phone: '' });
|
||||
|
||||
const action = useAction(enrollParticipant, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Teilnehmer angemeldet');
|
||||
setOpen(false);
|
||||
setForm({ firstName: '', lastName: '', email: '', phone: '' });
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(data?.error ?? 'Fehler bei der Anmeldung');
|
||||
}
|
||||
},
|
||||
onError: () => toast.error('Fehler bei der Anmeldung'),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />Teilnehmer anmelden</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Teilnehmer anmelden</DialogTitle>
|
||||
<DialogDescription>Melden Sie einen Teilnehmer für diesen Kurs an.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Vorname *</Label>
|
||||
<Input value={form.firstName} onChange={(e) => setForm(s => ({ ...s, firstName: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Nachname *</Label>
|
||||
<Input value={form.lastName} onChange={(e) => setForm(s => ({ ...s, lastName: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>E-Mail</Label>
|
||||
<Input type="email" value={form.email} onChange={(e) => setForm(s => ({ ...s, email: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Telefon</Label>
|
||||
<Input value={form.phone} onChange={(e) => setForm(s => ({ ...s, phone: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||
<Button
|
||||
onClick={() => action.execute({
|
||||
courseId,
|
||||
firstName: form.firstName,
|
||||
lastName: form.lastName,
|
||||
email: form.email || undefined,
|
||||
phone: form.phone || undefined,
|
||||
})}
|
||||
disabled={action.isPending || !form.firstName || !form.lastName}
|
||||
>
|
||||
{action.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Anmelden
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export { CreateCourseForm } from './create-course-form';
|
||||
export { EnrollParticipantDialog } from './enroll-participant-dialog';
|
||||
export { DeleteRefDataButton } from './delete-ref-data-button';
|
||||
|
||||
@@ -188,3 +188,32 @@ export const createSession = authActionClient
|
||||
logger.info({ name: 'course.createSession' }, 'Session created');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
// ── Delete reference data ──
|
||||
|
||||
export const deleteCategory = authActionClient
|
||||
.inputSchema(z.object({ id: z.string().uuid() }))
|
||||
.action(async ({ parsedInput: { id } }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createCourseManagementApi(client);
|
||||
await api.referenceData.deleteCategory(id);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
export const deleteInstructor = authActionClient
|
||||
.inputSchema(z.object({ id: z.string().uuid() }))
|
||||
.action(async ({ parsedInput: { id } }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createCourseManagementApi(client);
|
||||
await api.referenceData.deleteInstructor(id);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
export const deleteLocation = authActionClient
|
||||
.inputSchema(z.object({ id: z.string().uuid() }))
|
||||
.action(async ({ parsedInput: { id } }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createCourseManagementApi(client);
|
||||
await api.referenceData.deleteLocation(id);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -104,5 +104,80 @@ export function createCourseReferenceDataService(
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
// ── Update / Delete ──
|
||||
|
||||
async updateCategory(
|
||||
id: string,
|
||||
input: { name?: string; description?: string },
|
||||
) {
|
||||
const { error } = await client
|
||||
.from('course_categories')
|
||||
.update(input)
|
||||
.eq('id', id);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
async deleteCategory(id: string) {
|
||||
const { error } = await client
|
||||
.from('course_categories')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
async updateInstructor(
|
||||
id: string,
|
||||
input: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
qualifications?: string;
|
||||
hourlyRate?: number;
|
||||
},
|
||||
) {
|
||||
const update: Record<string, unknown> = {};
|
||||
if (input.firstName !== undefined) update.first_name = input.firstName;
|
||||
if (input.lastName !== undefined) update.last_name = input.lastName;
|
||||
if (input.email !== undefined) update.email = input.email;
|
||||
if (input.phone !== undefined) update.phone = input.phone;
|
||||
if (input.qualifications !== undefined)
|
||||
update.qualifications = input.qualifications;
|
||||
if (input.hourlyRate !== undefined) update.hourly_rate = input.hourlyRate;
|
||||
|
||||
const { error } = await client
|
||||
.from('course_instructors')
|
||||
.update(update)
|
||||
.eq('id', id);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
async deleteInstructor(id: string) {
|
||||
const { error } = await client
|
||||
.from('course_instructors')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
async updateLocation(
|
||||
id: string,
|
||||
input: { name?: string; address?: string; room?: string; capacity?: number },
|
||||
) {
|
||||
const { error } = await client
|
||||
.from('course_locations')
|
||||
.update(input)
|
||||
.eq('id', id);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
async deleteLocation(id: string) {
|
||||
const { error } = await client
|
||||
.from('course_locations')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
if (error) throw error;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,5 +35,8 @@
|
||||
"react": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Plus, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { createHolidayPass } from '../server/actions/event-actions';
|
||||
|
||||
interface CreateHolidayPassDialogProps {
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
export function CreateHolidayPassDialog({ accountId }: CreateHolidayPassDialogProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [form, setForm] = useState({ name: '', year: String(currentYear), description: '', price: '0', validFrom: '', validUntil: '' });
|
||||
|
||||
const action = useAction(createHolidayPass, {
|
||||
onSuccess: () => {
|
||||
toast.success('Ferienpass erstellt');
|
||||
setOpen(false);
|
||||
setForm({ name: '', year: String(currentYear), description: '', price: '0', validFrom: '', validUntil: '' });
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => toast.error('Fehler beim Erstellen'),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />Neuer Ferienpass</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ferienpass erstellen</DialogTitle>
|
||||
<DialogDescription>Erstellen Sie einen neuen Ferienpass für Ihr Ferienprogramm.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Name *</Label>
|
||||
<Input placeholder="z.B. Sommerferienprogramm" value={form.name} onChange={(e) => setForm(s => ({ ...s, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Jahr *</Label>
|
||||
<Input type="number" value={form.year} onChange={(e) => setForm(s => ({ ...s, year: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Beschreibung</Label>
|
||||
<Input placeholder="Optional" value={form.description} onChange={(e) => setForm(s => ({ ...s, description: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Preis (€)</Label>
|
||||
<Input type="number" step="0.01" min="0" value={form.price} onChange={(e) => setForm(s => ({ ...s, price: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Gültig ab</Label>
|
||||
<Input type="date" value={form.validFrom} onChange={(e) => setForm(s => ({ ...s, validFrom: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Gültig bis</Label>
|
||||
<Input type="date" value={form.validUntil} onChange={(e) => setForm(s => ({ ...s, validUntil: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||
<Button
|
||||
onClick={() => action.execute({
|
||||
accountId,
|
||||
name: form.name,
|
||||
year: Number(form.year),
|
||||
description: form.description || undefined,
|
||||
price: Number(form.price) || 0,
|
||||
validFrom: form.validFrom || undefined,
|
||||
validUntil: form.validUntil || undefined,
|
||||
})}
|
||||
disabled={action.isPending || !form.name || !form.year}
|
||||
>
|
||||
{action.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Erstellen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { UserPlus, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { registerForEvent } from '../server/actions/event-actions';
|
||||
|
||||
interface EventRegistrationDialogProps {
|
||||
eventId: string;
|
||||
eventName: string;
|
||||
}
|
||||
|
||||
export function EventRegistrationDialog({ eventId, eventName }: EventRegistrationDialogProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({ firstName: '', lastName: '', email: '', phone: '', dateOfBirth: '' });
|
||||
|
||||
const action = useAction(registerForEvent, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Anmeldung erfolgreich');
|
||||
setOpen(false);
|
||||
setForm({ firstName: '', lastName: '', email: '', phone: '', dateOfBirth: '' });
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(data?.error ?? 'Fehler bei der Anmeldung');
|
||||
}
|
||||
},
|
||||
onError: () => toast.error('Fehler bei der Anmeldung'),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><UserPlus className="mr-2 h-4 w-4" />Anmeldung</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Anmeldung zu „{eventName}"</DialogTitle>
|
||||
<DialogDescription>Melden Sie einen Teilnehmer an.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Vorname *</Label>
|
||||
<Input value={form.firstName} onChange={(e) => setForm(s => ({ ...s, firstName: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Nachname *</Label>
|
||||
<Input value={form.lastName} onChange={(e) => setForm(s => ({ ...s, lastName: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>E-Mail</Label>
|
||||
<Input type="email" value={form.email} onChange={(e) => setForm(s => ({ ...s, email: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Telefon</Label>
|
||||
<Input value={form.phone} onChange={(e) => setForm(s => ({ ...s, phone: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Geburtsdatum</Label>
|
||||
<Input type="date" value={form.dateOfBirth} onChange={(e) => setForm(s => ({ ...s, dateOfBirth: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||
<Button
|
||||
onClick={() => action.execute({
|
||||
eventId,
|
||||
firstName: form.firstName,
|
||||
lastName: form.lastName,
|
||||
email: form.email || undefined,
|
||||
phone: form.phone || undefined,
|
||||
dateOfBirth: form.dateOfBirth || undefined,
|
||||
})}
|
||||
disabled={action.isPending || !form.firstName || !form.lastName}
|
||||
>
|
||||
{action.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Anmelden
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export { CreateEventForm } from './create-event-form';
|
||||
export { EventRegistrationDialog } from './event-registration-dialog';
|
||||
export { CreateHolidayPassDialog } from './create-holiday-pass-dialog';
|
||||
|
||||
@@ -33,5 +33,8 @@
|
||||
"react": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { CreateInvoiceForm } from './create-invoice-form';
|
||||
export { CreateSepaBatchForm } from './create-sepa-batch-form';
|
||||
export { SepaBatchActions } from './sepa-batch-actions';
|
||||
|
||||
377
packages/features/finance/src/components/sepa-batch-actions.tsx
Normal file
377
packages/features/finance/src/components/sepa-batch-actions.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
'use client';
|
||||
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
Download,
|
||||
UserPlus,
|
||||
Plus,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import {
|
||||
populateBatchFromMembers,
|
||||
addSepaItem,
|
||||
generateSepaXml,
|
||||
} from '../server/actions/finance-actions';
|
||||
|
||||
interface SepaBatchActionsProps {
|
||||
batchId: string;
|
||||
accountId: string;
|
||||
batchStatus: string;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side toolbar for SEPA batch detail page.
|
||||
* Provides: populate from members, add single item, generate XML.
|
||||
*/
|
||||
export function SepaBatchActions({
|
||||
batchId,
|
||||
accountId,
|
||||
batchStatus,
|
||||
itemCount,
|
||||
}: SepaBatchActionsProps) {
|
||||
const router = useRouter();
|
||||
const isDraft = batchStatus === 'draft';
|
||||
|
||||
// ── Populate from members ──
|
||||
const populateAction = useAction(populateBatchFromMembers, {
|
||||
onSuccess: ({ data }) => {
|
||||
const count = data?.data?.addedCount ?? 0;
|
||||
if (count > 0) {
|
||||
toast.success(`${count} Mitglieder hinzugefügt`, {
|
||||
description: 'Positionen wurden aus der Mitgliederliste erstellt.',
|
||||
});
|
||||
} else {
|
||||
toast.info('Keine Mitglieder gefunden', {
|
||||
description:
|
||||
'Keine aktiven Mitglieder mit SEPA-Mandat und Beitragskategorie vorhanden.',
|
||||
});
|
||||
}
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Fehler beim Hinzufügen', {
|
||||
description: 'Bitte versuchen Sie es erneut.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// ── Add single item ──
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [singleItem, setSingleItem] = useState({
|
||||
debtorName: '',
|
||||
debtorIban: '',
|
||||
amount: '',
|
||||
remittanceInfo: 'Mitgliedsbeitrag',
|
||||
});
|
||||
|
||||
const addItemAction = useAction(addSepaItem, {
|
||||
onSuccess: () => {
|
||||
toast.success('Position hinzugefügt');
|
||||
setAddOpen(false);
|
||||
setSingleItem({
|
||||
debtorName: '',
|
||||
debtorIban: '',
|
||||
amount: '',
|
||||
remittanceInfo: 'Mitgliedsbeitrag',
|
||||
});
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Fehler beim Hinzufügen der Position');
|
||||
},
|
||||
});
|
||||
|
||||
// ── Generate XML ──
|
||||
const [xmlOpen, setXmlOpen] = useState(false);
|
||||
const [creditor, setCreditor] = useState({
|
||||
creditorName: '',
|
||||
creditorIban: '',
|
||||
creditorBic: '',
|
||||
creditorId: '',
|
||||
});
|
||||
|
||||
const xmlAction = useAction(generateSepaXml, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.data?.content) {
|
||||
// Trigger download
|
||||
const blob = new Blob([data.data.content], {
|
||||
type: 'application/xml',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = data.data.filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('SEPA-XML heruntergeladen');
|
||||
setXmlOpen(false);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Fehler beim Generieren der XML-Datei');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Populate from members — main CTA for draft batches */}
|
||||
{isDraft && (
|
||||
<Button
|
||||
onClick={() => populateAction.execute({ batchId, accountId })}
|
||||
disabled={populateAction.isPending}
|
||||
>
|
||||
{populateAction.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Mitglieder hinzufügen
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Add single position manually */}
|
||||
{isDraft && (
|
||||
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Einzelposition
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Position manuell hinzufügen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Geben Sie die Daten des Zahlungspflichtigen ein.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="debtor-name">Name *</Label>
|
||||
<Input
|
||||
id="debtor-name"
|
||||
placeholder="z.B. Max Mustermann"
|
||||
value={singleItem.debtorName}
|
||||
onChange={(e) =>
|
||||
setSingleItem((s) => ({
|
||||
...s,
|
||||
debtorName: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="debtor-iban">IBAN *</Label>
|
||||
<Input
|
||||
id="debtor-iban"
|
||||
placeholder="DE89 3704 0044 0532 0130 00"
|
||||
value={singleItem.debtorIban}
|
||||
onChange={(e) =>
|
||||
setSingleItem((s) => ({
|
||||
...s,
|
||||
debtorIban: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="debtor-amount">Betrag (EUR) *</Label>
|
||||
<Input
|
||||
id="debtor-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
placeholder="50.00"
|
||||
value={singleItem.amount}
|
||||
onChange={(e) =>
|
||||
setSingleItem((s) => ({
|
||||
...s,
|
||||
amount: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="debtor-info">Verwendungszweck</Label>
|
||||
<Input
|
||||
id="debtor-info"
|
||||
placeholder="Mitgliedsbeitrag 2026"
|
||||
value={singleItem.remittanceInfo}
|
||||
onChange={(e) =>
|
||||
setSingleItem((s) => ({
|
||||
...s,
|
||||
remittanceInfo: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddOpen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
addItemAction.execute({
|
||||
batchId,
|
||||
debtorName: singleItem.debtorName,
|
||||
debtorIban: singleItem.debtorIban.replace(/\s/g, ''),
|
||||
amount: parseFloat(singleItem.amount) || 0,
|
||||
remittanceInfo: singleItem.remittanceInfo || undefined,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
addItemAction.isPending ||
|
||||
!singleItem.debtorName ||
|
||||
!singleItem.debtorIban ||
|
||||
!singleItem.amount
|
||||
}
|
||||
>
|
||||
{addItemAction.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Generate & download XML */}
|
||||
<Dialog open={xmlOpen} onOpenChange={setXmlOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={itemCount === 0}
|
||||
title={
|
||||
itemCount === 0
|
||||
? 'Fügen Sie zuerst Positionen hinzu'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
XML herunterladen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>SEPA-XML generieren</DialogTitle>
|
||||
<DialogDescription>
|
||||
Geben Sie die Gläubiger-Daten Ihres Vereins ein. Diese werden im
|
||||
SEPA-XML als Zahlungsempfänger verwendet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="creditor-name">Gläubiger-Name (Verein) *</Label>
|
||||
<Input
|
||||
id="creditor-name"
|
||||
placeholder="z.B. Sportverein Musterstadt e.V."
|
||||
value={creditor.creditorName}
|
||||
onChange={(e) =>
|
||||
setCreditor((s) => ({ ...s, creditorName: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="creditor-iban">Gläubiger-IBAN *</Label>
|
||||
<Input
|
||||
id="creditor-iban"
|
||||
placeholder="DE89 3704 0044 0532 0130 00"
|
||||
value={creditor.creditorIban}
|
||||
onChange={(e) =>
|
||||
setCreditor((s) => ({ ...s, creditorIban: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="creditor-bic">Gläubiger-BIC *</Label>
|
||||
<Input
|
||||
id="creditor-bic"
|
||||
placeholder="z.B. COBADEFFXXX"
|
||||
value={creditor.creditorBic}
|
||||
onChange={(e) =>
|
||||
setCreditor((s) => ({ ...s, creditorBic: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="creditor-id">Gläubiger-ID *</Label>
|
||||
<Input
|
||||
id="creditor-id"
|
||||
placeholder="z.B. DE98ZZZ09999999999"
|
||||
value={creditor.creditorId}
|
||||
onChange={(e) =>
|
||||
setCreditor((s) => ({ ...s, creditorId: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Die Gläubiger-Identifikationsnummer erhalten Sie bei der
|
||||
Deutschen Bundesbank.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{itemCount === 0 && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-amber-50 p-3 text-sm text-amber-800">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
Keine Positionen vorhanden. Bitte fügen Sie zuerst Mitglieder
|
||||
hinzu.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setXmlOpen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
xmlAction.execute({
|
||||
batchId,
|
||||
accountId,
|
||||
creditorName: creditor.creditorName,
|
||||
creditorIban: creditor.creditorIban.replace(/\s/g, ''),
|
||||
creditorBic: creditor.creditorBic,
|
||||
creditorId: creditor.creditorId,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
xmlAction.isPending ||
|
||||
itemCount === 0 ||
|
||||
!creditor.creditorName ||
|
||||
!creditor.creditorIban ||
|
||||
!creditor.creditorBic ||
|
||||
!creditor.creditorId
|
||||
}
|
||||
>
|
||||
{xmlAction.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
XML generieren & herunterladen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -158,7 +158,11 @@ export function MemberDetailTabs({
|
||||
/>
|
||||
<DetailRow
|
||||
label="Geschlecht"
|
||||
value={String(member.gender ?? '—')}
|
||||
value={
|
||||
member.gender
|
||||
? { male: 'Männlich', female: 'Weiblich', diverse: 'Divers' }[member.gender as string] ?? String(member.gender)
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -211,7 +215,18 @@ export function MemberDetailTabs({
|
||||
/>
|
||||
<DetailRow
|
||||
label="Land"
|
||||
value={String(member.country ?? 'DE')}
|
||||
value={
|
||||
(() => {
|
||||
const code = String(member.country ?? 'DE');
|
||||
const countries: Record<string, string> = {
|
||||
DE: 'Deutschland', AT: 'Österreich', CH: 'Schweiz',
|
||||
LI: 'Liechtenstein', LU: 'Luxemburg', IT: 'Italien',
|
||||
FR: 'Frankreich', NL: 'Niederlande', BE: 'Belgien',
|
||||
PL: 'Polen', CZ: 'Tschechien', DK: 'Dänemark',
|
||||
};
|
||||
return countries[code] ?? code;
|
||||
})()
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -33,5 +33,8 @@
|
||||
"react": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Plus, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Textarea } from '@kit/ui/textarea';
|
||||
|
||||
import { createTemplate } from '../server/actions/newsletter-actions';
|
||||
|
||||
interface CreateTemplateDialogProps {
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
export function CreateTemplateDialog({ accountId }: CreateTemplateDialogProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', subject: '', bodyHtml: '<h1>Betreff</h1>\n<p>Inhalt hier...</p>' });
|
||||
|
||||
const action = useAction(createTemplate, {
|
||||
onSuccess: () => {
|
||||
toast.success('Vorlage erstellt');
|
||||
setOpen(false);
|
||||
setForm({ name: '', subject: '', bodyHtml: '<h1>Betreff</h1>\n<p>Inhalt hier...</p>' });
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => toast.error('Fehler beim Erstellen'),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />Neue Vorlage</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Newsletter-Vorlage erstellen</DialogTitle>
|
||||
<DialogDescription>Erstellen Sie eine wiederverwendbare Vorlage für Ihren Newsletter.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Name *</Label>
|
||||
<Input placeholder="z.B. Monatlicher Vereinsbrief" value={form.name} onChange={(e) => setForm(s => ({ ...s, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Betreff *</Label>
|
||||
<Input placeholder="z.B. Neuigkeiten aus dem Verein" value={form.subject} onChange={(e) => setForm(s => ({ ...s, subject: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Inhalt (HTML) *</Label>
|
||||
<Textarea rows={6} value={form.bodyHtml} onChange={(e) => setForm(s => ({ ...s, bodyHtml: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||
<Button
|
||||
onClick={() => action.execute({ accountId, name: form.name, subject: form.subject, bodyHtml: form.bodyHtml })}
|
||||
disabled={action.isPending || !form.name || !form.subject || !form.bodyHtml}
|
||||
>
|
||||
{action.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Erstellen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { CreateNewsletterForm } from './create-newsletter-form';
|
||||
export { CreateTemplateDialog } from './create-template-dialog';
|
||||
|
||||
Reference in New Issue
Block a user