Compare commits
9 Commits
feat/membe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cbe6652a1 | ||
|
|
ad01ecb8b9 | ||
|
|
7cfd88f1c3 | ||
|
|
1215e351c1 | ||
|
|
9f83b5cc75 | ||
|
|
5b169a381f | ||
|
|
9d5fe58ee3 | ||
| 4d538a5668 | |||
|
|
b6092adc3e |
@@ -1 +1 @@
|
|||||||
[]
|
[]
|
||||||
@@ -20,6 +20,8 @@ ARG NEXT_PUBLIC_ENABLE_MEETING_PROTOCOLS=true
|
|||||||
ARG NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG=true
|
ARG NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG=true
|
||||||
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||||
ARG NEXT_PUBLIC_BILLING_PROVIDER=stripe
|
ARG NEXT_PUBLIC_BILLING_PROVIDER=stripe
|
||||||
|
ARG NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=true
|
||||||
|
ARG NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true
|
||||||
ENV NEXT_PUBLIC_CI=${NEXT_PUBLIC_CI}
|
ENV NEXT_PUBLIC_CI=${NEXT_PUBLIC_CI}
|
||||||
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
|
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
|
||||||
ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
@@ -30,6 +32,8 @@ ENV NEXT_PUBLIC_ENABLE_MEETING_PROTOCOLS=${NEXT_PUBLIC_ENABLE_MEETING_PROTOCOLS}
|
|||||||
ENV NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG=${NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG}
|
ENV NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG=${NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG}
|
||||||
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
|
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
|
||||||
ENV NEXT_PUBLIC_BILLING_PROVIDER=${NEXT_PUBLIC_BILLING_PROVIDER}
|
ENV NEXT_PUBLIC_BILLING_PROVIDER=${NEXT_PUBLIC_BILLING_PROVIDER}
|
||||||
|
ENV NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=${NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING}
|
||||||
|
ENV NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=${NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING}
|
||||||
RUN pnpm --filter web build
|
RUN pnpm --filter web build
|
||||||
|
|
||||||
# --- Run (slim for smaller image than full Debian) ---
|
# --- Run (slim for smaller image than full Debian) ---
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
BedDouble,
|
BedDouble,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
LogIn,
|
|
||||||
LogOut,
|
|
||||||
XCircle,
|
|
||||||
User,
|
User,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { BookingStatusActions } from '@kit/booking-management/components';
|
||||||
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
|
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
@@ -288,41 +286,10 @@ export default async function BookingDetailPage({ params }: PageProps) {
|
|||||||
<CardDescription>{t('detail.changeStatus')}</CardDescription>
|
<CardDescription>{t('detail.changeStatus')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-wrap gap-3">
|
<BookingStatusActions
|
||||||
{(status === 'pending' || status === 'confirmed') && (
|
bookingId={bookingId}
|
||||||
<Button variant="default">
|
status={status}
|
||||||
<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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { CmsPageShell } from '~/components/cms-page-shell';
|
|||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||||
@@ -51,8 +52,9 @@ function isDateInRange(
|
|||||||
return date >= checkIn && date < checkOut;
|
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 { account } = await params;
|
||||||
|
const search = await searchParams;
|
||||||
const t = await getTranslations('bookings');
|
const t = await getTranslations('bookings');
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
@@ -73,8 +75,15 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
|||||||
const api = createBookingManagementApi(client);
|
const api = createBookingManagementApi(client);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getFullYear();
|
const year = Number(search.year) || now.getFullYear();
|
||||||
const month = now.getMonth();
|
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 daysInMonth = getDaysInMonth(year, month);
|
||||||
const firstWeekday = getFirstWeekday(year, month);
|
const firstWeekday = getFirstWeekday(year, month);
|
||||||
|
|
||||||
@@ -160,10 +169,12 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled
|
asChild
|
||||||
aria-label={t('calendar.previousMonth')}
|
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>
|
</Button>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{MONTH_NAMES[month]} {year}
|
{MONTH_NAMES[month]} {year}
|
||||||
@@ -171,10 +182,12 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled
|
asChild
|
||||||
aria-label={t('calendar.nextMonth')}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { UserCircle, Plus } from 'lucide-react';
|
|||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||||
|
import { CreateGuestDialog } from '@kit/booking-management/components';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
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 w-full flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground">{t('guests.manage')}</p>
|
<p className="text-muted-foreground">{t('guests.manage')}</p>
|
||||||
<Button data-test="guests-new-btn">
|
<CreateGuestDialog accountId={acct.id} />
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{t('guests.newGuest')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{guests.length === 0 ? (
|
{guests.length === 0 ? (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BedDouble, Plus } from 'lucide-react';
|
|||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||||
|
import { CreateRoomDialog } from '@kit/booking-management/components';
|
||||||
import { formatCurrencyAmount } from '@kit/shared/dates';
|
import { formatCurrencyAmount } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
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 w-full flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground">{t('rooms.manage')}</p>
|
<p className="text-muted-foreground">{t('rooms.manage')}</p>
|
||||||
<Button data-test="rooms-new-btn">
|
<CreateRoomDialog accountId={acct.id} />
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{t('rooms.newRoom')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rooms.length === 0 ? (
|
{rooms.length === 0 ? (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Plus, Users } from 'lucide-react';
|
|||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||||
|
import { EnrollParticipantDialog } from '@kit/course-management/components';
|
||||||
import { formatDate } from '@kit/shared/dates';
|
import { formatDate } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
@@ -56,10 +57,7 @@ export default async function ParticipantsPage({ params }: PageProps) {
|
|||||||
{participants.length} {t('participants.title')}
|
{participants.length} {t('participants.title')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button data-test="participants-add-btn">
|
<EnrollParticipantDialog courseId={courseId} />
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{t('participants.add')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{participants.length === 0 ? (
|
{participants.length === 0 ? (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { FolderTree } from 'lucide-react';
|
|||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||||
|
import { DeleteRefDataButton } from '@kit/course-management/components';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
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">
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
{t('common.parent')}
|
{t('common.parent')}
|
||||||
</th>
|
</th>
|
||||||
|
<th scope="col" className="w-16 p-3" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -80,6 +82,13 @@ export default async function CategoriesPage({ params }: PageProps) {
|
|||||||
{String(cat.description ?? '—')}
|
{String(cat.description ?? '—')}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">{String(cat.parent_id ?? '—')}</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { GraduationCap } from 'lucide-react';
|
|||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||||
|
import { DeleteRefDataButton } from '@kit/course-management/components';
|
||||||
import { formatCurrencyAmount } from '@kit/shared/dates';
|
import { formatCurrencyAmount } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
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">
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
{t('instructors.hourlyRate')}
|
{t('instructors.hourlyRate')}
|
||||||
</th>
|
</th>
|
||||||
|
<th scope="col" className="w-16 p-3" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -96,6 +98,13 @@ export default async function InstructorsPage({ params }: PageProps) {
|
|||||||
? formatCurrencyAmount(inst.hourly_rate as number)
|
? formatCurrencyAmount(inst.hourly_rate as number)
|
||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
<DeleteRefDataButton
|
||||||
|
id={String(inst.id)}
|
||||||
|
type="instructor"
|
||||||
|
itemName={`${inst.first_name} ${inst.last_name}`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { MapPin } from 'lucide-react';
|
|||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||||
|
import { DeleteRefDataButton } from '@kit/course-management/components';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
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">
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
{t('list.capacity')}
|
{t('list.capacity')}
|
||||||
</th>
|
</th>
|
||||||
|
<th scope="col" className="w-16 p-3" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -89,6 +91,13 @@ export default async function LocationsPage({ params }: PageProps) {
|
|||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
{String(loc.capacity ?? '—')}
|
{String(loc.capacity ?? '—')}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
<DeleteRefDataButton
|
||||||
|
id={String(loc.id)}
|
||||||
|
type="location"
|
||||||
|
itemName={String(loc.name)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -26,13 +26,19 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
|
|||||||
|
|
||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
// Document templates are stored locally for now — placeholder for future DB integration
|
// Fetch document templates from DB
|
||||||
const templates: Array<{
|
const { data: templates } = await client
|
||||||
id: string;
|
.from('document_templates')
|
||||||
name: string;
|
.select('id, name, template_type, description')
|
||||||
type: string;
|
.eq('account_id', acct.id)
|
||||||
description: string;
|
.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 (
|
return (
|
||||||
<CmsPageShell account={account} title={t('templates.title')}>
|
<CmsPageShell account={account} title={t('templates.title')}>
|
||||||
@@ -50,7 +56,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table or Empty State */}
|
{/* Table or Empty State */}
|
||||||
{templates.length === 0 ? (
|
{templatesList.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<FileText className="h-8 w-8" />}
|
icon={<FileText className="h-8 w-8" />}
|
||||||
title={t('templates.noTemplates')}
|
title={t('templates.noTemplates')}
|
||||||
@@ -61,7 +67,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{t('templates.allTemplates', { count: templates.length })}
|
{t('templates.allTemplates', { count: templatesList.length })}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -81,7 +87,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{templates.map((template) => (
|
{templatesList.map((template) => (
|
||||||
<tr
|
<tr
|
||||||
key={template.id}
|
key={template.id}
|
||||||
className="hover:bg-muted/30 border-b"
|
className="hover:bg-muted/30 border-b"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { createEventManagementApi } from '@kit/event-management/api';
|
import { createEventManagementApi } from '@kit/event-management/api';
|
||||||
|
import { EventRegistrationDialog } from '@kit/event-management/components';
|
||||||
import { formatDate } from '@kit/shared/dates';
|
import { formatDate } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
@@ -73,10 +74,7 @@ export default async function EventDetailPage({ params }: PageProps) {
|
|||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Button>
|
<EventRegistrationDialog eventId={eventId} eventName={String((event as any).name ?? '')} />
|
||||||
<UserPlus className="mr-2 h-4 w-4" />
|
|
||||||
{t('register')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail Cards */}
|
{/* Detail Cards */}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Ticket, Plus } from 'lucide-react';
|
|||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { createEventManagementApi } from '@kit/event-management/api';
|
import { createEventManagementApi } from '@kit/event-management/api';
|
||||||
|
import { CreateHolidayPassDialog } from '@kit/event-management/components';
|
||||||
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
|
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
@@ -40,10 +41,7 @@ export default async function HolidayPassesPage({ params }: PageProps) {
|
|||||||
{t('holidayPassesDescription')}
|
{t('holidayPassesDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button>
|
<CreateHolidayPassDialog accountId={acct.id} />
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{t('newHolidayPass')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{passes.length === 0 ? (
|
{passes.length === 0 ? (
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { ArrowLeft, Download } from 'lucide-react';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { createFinanceApi } from '@kit/finance/api';
|
import { createFinanceApi } from '@kit/finance/api';
|
||||||
|
import { SepaBatchActions } from '@kit/finance/components';
|
||||||
import { formatDate } from '@kit/shared/dates';
|
import { formatDate } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
@@ -124,10 +124,12 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
|
|||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<Button disabled variant="outline">
|
<SepaBatchActions
|
||||||
<Download className="mr-2 h-4 w-4" />
|
batchId={batchId}
|
||||||
{t('sepa.downloadXml')}
|
accountId={acct.id}
|
||||||
</Button>
|
batchStatus={status}
|
||||||
|
itemCount={items.length}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export default async function SepaPage({ params }: PageProps) {
|
|||||||
{batches.map((batch: Record<string, unknown>) => (
|
{batches.map((batch: Record<string, unknown>) => (
|
||||||
<tr
|
<tr
|
||||||
key={String(batch.id)}
|
key={String(batch.id)}
|
||||||
className="hover:bg-muted/30 border-b"
|
className="hover:bg-muted/30 cursor-pointer border-b"
|
||||||
>
|
>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Badge
|
<Badge
|
||||||
@@ -131,7 +131,7 @@ export default async function SepaPage({ params }: PageProps) {
|
|||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Link
|
<Link
|
||||||
href={`/home/${account}/finance/sepa/${String(batch.id)}`}
|
href={`/home/${account}/finance/sepa/${String(batch.id)}`}
|
||||||
className="hover:underline"
|
className="font-medium hover:underline"
|
||||||
>
|
>
|
||||||
{String(batch.description ?? '—')}
|
{String(batch.description ?? '—')}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -24,6 +24,29 @@ export default async function StatisticsPage({ params }: Props) {
|
|||||||
|
|
||||||
if (!acct) return <AccountNotFound />;
|
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 (
|
return (
|
||||||
<CmsPageShell account={account} title={t('pages.statisticsTitle')}>
|
<CmsPageShell account={account} title={t('pages.statisticsTitle')}>
|
||||||
<FischereiTabNavigation account={account} activeTab="statistics" />
|
<FischereiTabNavigation account={account} activeTab="statistics" />
|
||||||
@@ -33,22 +56,27 @@ export default async function StatisticsPage({ params }: Props) {
|
|||||||
Fangstatistiken und Auswertungen
|
Fangstatistiken und Auswertungen
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardContent className="p-6">
|
||||||
<CardTitle>Fangstatistiken</CardTitle>
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
|
||||||
</CardHeader>
|
<h3 className="text-lg font-semibold">Noch keine Daten vorhanden</h3>
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
|
|
||||||
<h3 className="text-lg font-semibold">
|
|
||||||
Noch keine Daten vorhanden
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
|
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
|
||||||
Sobald Fangbücher eingereicht und geprüft werden, erscheinen
|
Sobald Gewässer, Fischarten und Fangbücher angelegt werden, erscheinen hier detaillierte Statistiken.
|
||||||
hier Statistiken und Auswertungen.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,40 +45,54 @@ export default async function MembersPage({ params, searchParams }: Props) {
|
|||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch categories, departments, and tags in parallel
|
// Fetch categories and departments (always available)
|
||||||
const [duesCategories, departments, tagsResult, tagAssignmentsResult] =
|
const [duesCategories, departments] = await Promise.all([
|
||||||
await Promise.all([
|
organization.listDuesCategories(acct.id),
|
||||||
organization.listDuesCategories(acct.id),
|
organization.listDepartmentsWithCounts(acct.id),
|
||||||
organization.listDepartmentsWithCounts(acct.id),
|
]);
|
||||||
(client.from as any)('member_tags')
|
|
||||||
.select('id, name, color')
|
|
||||||
.eq('account_id', acct.id)
|
|
||||||
.order('sort_order'),
|
|
||||||
(client.from as any)('member_tag_assignments')
|
|
||||||
.select('member_id, tag_id, member_tags(id, name, color)')
|
|
||||||
.in(
|
|
||||||
'member_id',
|
|
||||||
result.data.map((m: any) => m.id),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Build memberTags lookup: { memberId: [{ id, name, color }] }
|
// Fetch tags gracefully (tables may not exist if migration hasn't run)
|
||||||
|
let accountTags: Array<{ id: string; name: string; color: string }> = [];
|
||||||
const memberTags: Record<
|
const memberTags: Record<
|
||||||
string,
|
string,
|
||||||
Array<{ id: string; name: string; color: string }>
|
Array<{ id: string; name: string; color: string }>
|
||||||
> = {};
|
> = {};
|
||||||
|
|
||||||
for (const a of tagAssignmentsResult.data ?? []) {
|
try {
|
||||||
const memberId = String(a.member_id);
|
const memberIds = result.data.map((m: any) => m.id);
|
||||||
const tag = a.member_tags;
|
|
||||||
if (!tag) continue;
|
|
||||||
|
|
||||||
if (!memberTags[memberId]) memberTags[memberId] = [];
|
const [tagsResult, tagAssignmentsResult] = await Promise.all([
|
||||||
memberTags[memberId]!.push({
|
(client.from as any)('member_tags')
|
||||||
id: String(tag.id),
|
.select('id, name, color')
|
||||||
name: String(tag.name),
|
.eq('account_id', acct.id)
|
||||||
color: String(tag.color),
|
.order('sort_order'),
|
||||||
});
|
memberIds.length > 0
|
||||||
|
? (client.from as any)('member_tag_assignments')
|
||||||
|
.select('member_id, tag_id, member_tags(id, name, color)')
|
||||||
|
.in('member_id', memberIds)
|
||||||
|
: { data: [] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
accountTags = (tagsResult.data ?? []).map((t: any) => ({
|
||||||
|
id: String(t.id),
|
||||||
|
name: String(t.name),
|
||||||
|
color: String(t.color),
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const a of tagAssignmentsResult.data ?? []) {
|
||||||
|
const memberId = String(a.member_id);
|
||||||
|
const tag = a.member_tags;
|
||||||
|
if (!tag) continue;
|
||||||
|
|
||||||
|
if (!memberTags[memberId]) memberTags[memberId] = [];
|
||||||
|
memberTags[memberId]!.push({
|
||||||
|
id: String(tag.id),
|
||||||
|
name: String(tag.name),
|
||||||
|
color: String(tag.color),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Tags tables may not exist yet — gracefully degrade
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -100,11 +114,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
|
|||||||
name: String(d.name),
|
name: String(d.name),
|
||||||
memberCount: d.memberCount,
|
memberCount: d.memberCount,
|
||||||
}))}
|
}))}
|
||||||
tags={(tagsResult.data ?? []).map((t: any) => ({
|
tags={accountTags}
|
||||||
id: String(t.id),
|
|
||||||
name: String(t.name),
|
|
||||||
color: String(t.color),
|
|
||||||
}))}
|
|
||||||
memberTags={memberTags}
|
memberTags={memberTags}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ export default async function MemberStatisticsPage({ params }: PageProps) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const { query } = createMemberServices(client);
|
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 = [
|
const statusChartData = [
|
||||||
{ name: t('status.active'), value: stats.active ?? 0 },
|
{ 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 { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { createNewsletterApi } from '@kit/newsletter/api';
|
import { createNewsletterApi } from '@kit/newsletter/api';
|
||||||
|
import { CreateTemplateDialog } from '@kit/newsletter/components';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
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>
|
<p className="text-muted-foreground">{t('templates.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button data-test="newsletter-templates-new-btn">
|
<CreateTemplateDialog accountId={acct.id} />
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{t('templates.newTemplate')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table or Empty State */}
|
{/* Table or Empty State */}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
@@ -45,9 +47,11 @@ export default async function PostsManagerPage({ params }: Props) {
|
|||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button data-test="site-new-post-btn">
|
<Button data-test="site-new-post-btn" asChild>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Link href={`/home/${account}/site-builder/posts/new`}>
|
||||||
{t('posts.newPost')}
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{t('posts.newPost')}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{posts.length === 0 ? (
|
{posts.length === 0 ? (
|
||||||
|
|||||||
@@ -22,15 +22,60 @@ const PLACEHOLDER_DATA = [
|
|||||||
{ year: '2025', vereine: 19, mitglieder: 1200 },
|
{ 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Entwicklung der Mitgliedsvereine und Gesamtmitglieder im Zeitverlauf
|
Aktuelle Kennzahlen des Verbands
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { VerbandTabNavigation } from '@kit/verbandsverwaltung/components';
|
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 { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
import StatisticsContent from './_components/statistics-content';
|
import StatisticsContent from './_components/statistics-content';
|
||||||
@@ -13,11 +15,50 @@ interface Props {
|
|||||||
export default async function StatisticsPage({ params }: Props) {
|
export default async function StatisticsPage({ params }: Props) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const t = await getTranslations('verband');
|
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 (
|
return (
|
||||||
<CmsPageShell account={account} title={t('pages.statisticsTitle')}>
|
<CmsPageShell account={account} title={t('pages.statisticsTitle')}>
|
||||||
<VerbandTabNavigation account={account} activeTab="statistics" />
|
<VerbandTabNavigation account={account} activeTab="statistics" />
|
||||||
<StatisticsContent />
|
<StatisticsContent
|
||||||
|
activeClubs={activeClubs}
|
||||||
|
totalClubs={clubs.length}
|
||||||
|
totalMembers={totalMembers || directMembers}
|
||||||
|
openFees={openFees}
|
||||||
|
/>
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,58 @@
|
|||||||
"administration": "Administration",
|
"administration": "Administration",
|
||||||
"accountSettings": "Kontoeinstellungen",
|
"accountSettings": "Kontoeinstellungen",
|
||||||
"application": "Anwendung",
|
"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": {
|
"roles": {
|
||||||
"owner": {
|
"owner": {
|
||||||
@@ -223,4 +274,4 @@
|
|||||||
"action": "Zum Dashboard"
|
"action": "Zum Dashboard"
|
||||||
},
|
},
|
||||||
"confirm": "Bestätigen"
|
"confirm": "Bestätigen"
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,58 @@
|
|||||||
"associationTemplates": "Shared Templates",
|
"associationTemplates": "Shared Templates",
|
||||||
"administration": "Administration",
|
"administration": "Administration",
|
||||||
"accountSettings": "Account Settings",
|
"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": {
|
"roles": {
|
||||||
"owner": {
|
"owner": {
|
||||||
@@ -223,4 +274,4 @@
|
|||||||
"action": "Go to Dashboard"
|
"action": "Go to Dashboard"
|
||||||
},
|
},
|
||||||
"confirm": "Confirm"
|
"confirm": "Confirm"
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
BIN
apps/web/public/images/posts/digitale-verwaltung.webp
Normal file
BIN
apps/web/public/images/posts/digitale-verwaltung.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 216 KiB |
BIN
apps/web/public/images/posts/dsgvo-vereine.webp
Normal file
BIN
apps/web/public/images/posts/dsgvo-vereine.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
BIN
apps/web/public/images/posts/mitgliederverwaltung.webp
Normal file
BIN
apps/web/public/images/posts/mitgliederverwaltung.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 216 KiB |
BIN
apps/web/public/images/posts/sepa-lastschrift.webp
Normal file
BIN
apps/web/public/images/posts/sepa-lastschrift.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/web/public/images/posts/vereinswebsite.webp
Normal file
BIN
apps/web/public/images/posts/vereinswebsite.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
@@ -17,7 +17,11 @@ UPDATE public.members SET exit_date = entry_date
|
|||||||
UPDATE public.members SET entry_date = current_date
|
UPDATE public.members SET entry_date = current_date
|
||||||
WHERE entry_date IS NOT NULL AND entry_date > current_date;
|
WHERE entry_date IS NOT NULL AND entry_date > current_date;
|
||||||
|
|
||||||
-- Normalize IBANs in sepa_mandates to uppercase, strip spaces
|
-- Normalize IBANs to uppercase, strip spaces (both tables)
|
||||||
|
UPDATE public.members
|
||||||
|
SET iban = upper(regexp_replace(iban, '\s', '', 'g'))
|
||||||
|
WHERE iban IS NOT NULL AND iban != '';
|
||||||
|
|
||||||
UPDATE public.sepa_mandates
|
UPDATE public.sepa_mandates
|
||||||
SET iban = upper(regexp_replace(iban, '\s', '', 'g'))
|
SET iban = upper(regexp_replace(iban, '\s', '', 'g'))
|
||||||
WHERE iban IS NOT NULL AND iban != '';
|
WHERE iban IS NOT NULL AND iban != '';
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ CREATE INDEX IF NOT EXISTS ix_event_registrations_member
|
|||||||
-- Backfill: match existing registrations to members by email within the same account
|
-- Backfill: match existing registrations to members by email within the same account
|
||||||
UPDATE public.event_registrations er
|
UPDATE public.event_registrations er
|
||||||
SET member_id = m.id
|
SET member_id = m.id
|
||||||
FROM public.events e
|
FROM public.events e, public.members m
|
||||||
JOIN public.members m ON m.account_id = e.account_id
|
WHERE e.id = er.event_id
|
||||||
|
AND m.account_id = e.account_id
|
||||||
AND lower(m.email) = lower(er.email)
|
AND lower(m.email) = lower(er.email)
|
||||||
AND m.email IS NOT NULL AND m.email != ''
|
AND m.email IS NOT NULL AND m.email != ''
|
||||||
AND m.status IN ('active', 'inactive', 'pending')
|
AND m.status IN ('active', 'inactive', 'pending')
|
||||||
WHERE e.id = er.event_id
|
|
||||||
AND er.member_id IS NULL
|
AND er.member_id IS NULL
|
||||||
AND er.email IS NOT NULL AND er.email != '';
|
AND er.email IS NOT NULL AND er.email != '';
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ CREATE OR REPLACE FUNCTION public.transfer_member(
|
|||||||
p_member_id uuid,
|
p_member_id uuid,
|
||||||
p_target_account_id uuid,
|
p_target_account_id uuid,
|
||||||
p_reason text DEFAULT NULL,
|
p_reason text DEFAULT NULL,
|
||||||
p_keep_sepa boolean DEFAULT false
|
p_keep_sepa boolean DEFAULT true
|
||||||
)
|
)
|
||||||
RETURNS uuid
|
RETURNS uuid
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Enable btree_gist extension (required by booking overlap exclusion constraint)
|
||||||
|
-- Separated into own migration to avoid "multiple commands in prepared statement" error
|
||||||
|
CREATE EXTENSION IF NOT EXISTS btree_gist;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
CREATE OR REPLACE FUNCTION public.create_booking_atomic(
|
||||||
|
p_account_id uuid,
|
||||||
|
p_room_id uuid,
|
||||||
|
p_guest_id uuid DEFAULT NULL,
|
||||||
|
p_check_in date DEFAULT NULL,
|
||||||
|
p_check_out date DEFAULT NULL,
|
||||||
|
p_adults integer DEFAULT 1,
|
||||||
|
p_children integer DEFAULT 0,
|
||||||
|
p_status text DEFAULT 'confirmed',
|
||||||
|
p_total_price numeric DEFAULT NULL,
|
||||||
|
p_notes text DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS uuid
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $fn$
|
||||||
|
DECLARE
|
||||||
|
v_room record;
|
||||||
|
v_computed_price numeric(10,2);
|
||||||
|
v_booking_id uuid;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_room FROM public.rooms WHERE id = p_room_id FOR UPDATE;
|
||||||
|
IF v_room IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Room % not found', p_room_id USING ERRCODE = 'P0002';
|
||||||
|
END IF;
|
||||||
|
IF p_check_in IS NULL OR p_check_out IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'check_in and check_out dates are required' USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
IF p_check_out <= p_check_in THEN
|
||||||
|
RAISE EXCEPTION 'check_out must be after check_in' USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
IF (p_adults + p_children) > v_room.capacity THEN
|
||||||
|
RAISE EXCEPTION 'Total guests exceed room capacity' USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF p_total_price IS NOT NULL THEN
|
||||||
|
v_computed_price := p_total_price;
|
||||||
|
ELSE
|
||||||
|
v_computed_price := v_room.price_per_night * (p_check_out - p_check_in);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO public.bookings (
|
||||||
|
account_id, room_id, guest_id, check_in, check_out,
|
||||||
|
adults, children, status, total_price, notes
|
||||||
|
) VALUES (
|
||||||
|
p_account_id, p_room_id, p_guest_id, p_check_in, p_check_out,
|
||||||
|
p_adults, p_children, p_status, v_computed_price, p_notes
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_booking_id;
|
||||||
|
|
||||||
|
RETURN v_booking_id;
|
||||||
|
END;
|
||||||
|
$fn$;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO authenticated;
|
||||||
@@ -1,25 +1,4 @@
|
|||||||
-- =====================================================
|
DO $excl$
|
||||||
-- Atomic Booking Creation with Overlap Prevention
|
|
||||||
--
|
|
||||||
-- Problem: Creating a booking requires checking room
|
|
||||||
-- availability, validating capacity, and inserting — all
|
|
||||||
-- as separate queries. Race conditions can double-book
|
|
||||||
-- a room for overlapping dates.
|
|
||||||
--
|
|
||||||
-- Fix:
|
|
||||||
-- A) Enable btree_gist extension for exclusion constraints.
|
|
||||||
-- B) Add GiST exclusion constraint to prevent overlapping
|
|
||||||
-- bookings for the same room (non-cancelled/no_show).
|
|
||||||
-- C) Single transactional PG function that locks the room,
|
|
||||||
-- validates inputs, calculates price, and inserts. The
|
|
||||||
-- exclusion constraint provides a final safety net.
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- A) Enable btree_gist extension (required for exclusion constraints on non-GiST types)
|
|
||||||
CREATE EXTENSION IF NOT EXISTS btree_gist;
|
|
||||||
|
|
||||||
-- B) Add exclusion constraint to prevent overlapping bookings (idempotent)
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
SELECT 1 FROM pg_constraint WHERE conname = 'excl_booking_room_dates'
|
SELECT 1 FROM pg_constraint WHERE conname = 'excl_booking_room_dates'
|
||||||
@@ -32,97 +11,4 @@ BEGIN
|
|||||||
) WHERE (status NOT IN ('cancelled', 'no_show'));
|
) WHERE (status NOT IN ('cancelled', 'no_show'));
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$excl$;
|
||||||
|
|
||||||
-- C) Atomic booking creation function
|
|
||||||
CREATE OR REPLACE FUNCTION public.create_booking_atomic(
|
|
||||||
p_account_id uuid,
|
|
||||||
p_room_id uuid,
|
|
||||||
p_guest_id uuid DEFAULT NULL,
|
|
||||||
p_check_in date DEFAULT NULL,
|
|
||||||
p_check_out date DEFAULT NULL,
|
|
||||||
p_adults integer DEFAULT 1,
|
|
||||||
p_children integer DEFAULT 0,
|
|
||||||
p_status text DEFAULT 'confirmed',
|
|
||||||
p_total_price numeric DEFAULT NULL,
|
|
||||||
p_notes text DEFAULT NULL
|
|
||||||
)
|
|
||||||
RETURNS uuid
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
SECURITY DEFINER
|
|
||||||
SET search_path = ''
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
v_room record;
|
|
||||||
v_computed_price numeric(10,2);
|
|
||||||
v_booking_id uuid;
|
|
||||||
BEGIN
|
|
||||||
-- 1. Lock the room row to serialize booking attempts
|
|
||||||
SELECT * INTO v_room
|
|
||||||
FROM public.rooms
|
|
||||||
WHERE id = p_room_id
|
|
||||||
FOR UPDATE;
|
|
||||||
|
|
||||||
-- 2. Validate room exists
|
|
||||||
IF v_room IS NULL THEN
|
|
||||||
RAISE EXCEPTION 'Room % not found', p_room_id
|
|
||||||
USING ERRCODE = 'P0002';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- 3. Validate check_out > check_in
|
|
||||||
IF p_check_in IS NULL OR p_check_out IS NULL THEN
|
|
||||||
RAISE EXCEPTION 'check_in and check_out dates are required'
|
|
||||||
USING ERRCODE = 'P0001';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF p_check_out <= p_check_in THEN
|
|
||||||
RAISE EXCEPTION 'check_out (%) must be after check_in (%)', p_check_out, p_check_in
|
|
||||||
USING ERRCODE = 'P0001';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- 4. Validate total guests do not exceed room capacity
|
|
||||||
IF (p_adults + p_children) > v_room.capacity THEN
|
|
||||||
RAISE EXCEPTION 'Total guests (%) exceed room capacity (%)', (p_adults + p_children), v_room.capacity
|
|
||||||
USING ERRCODE = 'P0001';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- 5. Calculate price if not provided
|
|
||||||
IF p_total_price IS NOT NULL THEN
|
|
||||||
v_computed_price := p_total_price;
|
|
||||||
ELSE
|
|
||||||
v_computed_price := v_room.price_per_night * (p_check_out - p_check_in);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- 6. Insert the booking (exclusion constraint prevents double-booking)
|
|
||||||
INSERT INTO public.bookings (
|
|
||||||
account_id,
|
|
||||||
room_id,
|
|
||||||
guest_id,
|
|
||||||
check_in,
|
|
||||||
check_out,
|
|
||||||
adults,
|
|
||||||
children,
|
|
||||||
status,
|
|
||||||
total_price,
|
|
||||||
notes
|
|
||||||
) VALUES (
|
|
||||||
p_account_id,
|
|
||||||
p_room_id,
|
|
||||||
p_guest_id,
|
|
||||||
p_check_in,
|
|
||||||
p_check_out,
|
|
||||||
p_adults,
|
|
||||||
p_children,
|
|
||||||
p_status,
|
|
||||||
v_computed_price,
|
|
||||||
p_notes
|
|
||||||
)
|
|
||||||
RETURNING id INTO v_booking_id;
|
|
||||||
|
|
||||||
-- 7. Return the new booking id
|
|
||||||
RETURN v_booking_id;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO authenticated;
|
|
||||||
GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO service_role;
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Module Notification Rules & Queue
|
||||||
|
-- Shared notification infrastructure for courses, events, bookings.
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Notification rules: define what triggers notifications
|
||||||
|
CREATE TABLE IF NOT EXISTS public.module_notification_rules (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
module text NOT NULL CHECK (module IN ('courses', 'events', 'bookings')),
|
||||||
|
trigger_event text NOT NULL CHECK (trigger_event IN (
|
||||||
|
'course.participant_enrolled', 'course.participant_waitlisted', 'course.participant_promoted',
|
||||||
|
'course.participant_cancelled', 'course.status_changed', 'course.session_reminder',
|
||||||
|
'event.registration_confirmed', 'event.registration_waitlisted', 'event.registration_promoted',
|
||||||
|
'event.registration_cancelled', 'event.status_changed', 'event.reminder',
|
||||||
|
'booking.confirmed', 'booking.check_in_reminder', 'booking.checked_in',
|
||||||
|
'booking.checked_out', 'booking.cancelled'
|
||||||
|
)),
|
||||||
|
channel text NOT NULL DEFAULT 'in_app' CHECK (channel IN ('in_app', 'email', 'both')),
|
||||||
|
recipient_type text NOT NULL DEFAULT 'admin' CHECK (recipient_type IN ('admin', 'participant', 'guest', 'instructor', 'specific_user')),
|
||||||
|
recipient_config jsonb NOT NULL DEFAULT '{}',
|
||||||
|
subject_template text,
|
||||||
|
message_template text NOT NULL,
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_module_notification_rules_lookup
|
||||||
|
ON public.module_notification_rules(account_id, module, trigger_event)
|
||||||
|
WHERE is_active = true;
|
||||||
|
|
||||||
|
ALTER TABLE public.module_notification_rules ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.module_notification_rules FROM authenticated, service_role;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.module_notification_rules TO authenticated;
|
||||||
|
GRANT ALL ON public.module_notification_rules TO service_role;
|
||||||
|
|
||||||
|
CREATE POLICY module_notification_rules_select ON public.module_notification_rules
|
||||||
|
FOR SELECT TO authenticated USING (public.has_role_on_account(account_id));
|
||||||
|
|
||||||
|
CREATE POLICY module_notification_rules_mutate ON public.module_notification_rules
|
||||||
|
FOR ALL TO authenticated USING (
|
||||||
|
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
|
||||||
|
) WITH CHECK (
|
||||||
|
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Pending notifications queue
|
||||||
|
CREATE TABLE IF NOT EXISTS public.pending_module_notifications (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
module text NOT NULL CHECK (module IN ('courses', 'events', 'bookings')),
|
||||||
|
trigger_event text NOT NULL,
|
||||||
|
entity_id uuid NOT NULL,
|
||||||
|
context jsonb NOT NULL DEFAULT '{}',
|
||||||
|
processed_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_pending_module_notifications_unprocessed
|
||||||
|
ON public.pending_module_notifications(created_at)
|
||||||
|
WHERE processed_at IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE public.pending_module_notifications ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.pending_module_notifications FROM authenticated, service_role;
|
||||||
|
GRANT SELECT ON public.pending_module_notifications TO authenticated;
|
||||||
|
GRANT ALL ON public.pending_module_notifications TO service_role;
|
||||||
|
|
||||||
|
CREATE POLICY pending_module_notifications_select ON public.pending_module_notifications
|
||||||
|
FOR SELECT TO authenticated USING (public.has_role_on_account(account_id));
|
||||||
|
|
||||||
|
-- Enqueue helper
|
||||||
|
CREATE OR REPLACE FUNCTION public.enqueue_module_notification(
|
||||||
|
p_account_id uuid,
|
||||||
|
p_module text,
|
||||||
|
p_trigger_event text,
|
||||||
|
p_entity_id uuid,
|
||||||
|
p_context jsonb DEFAULT '{}'
|
||||||
|
)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.pending_module_notifications
|
||||||
|
(account_id, module, trigger_event, entity_id, context)
|
||||||
|
VALUES
|
||||||
|
(p_account_id, p_module, p_trigger_event, p_entity_id, p_context);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.enqueue_module_notification(uuid, text, text, uuid, jsonb) TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.enqueue_module_notification(uuid, text, text, uuid, jsonb) TO service_role;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* -------------------------------------------------------
|
||||||
|
* Shared Communications Table for Courses, Events, Bookings
|
||||||
|
* Tracks email, phone, letter, meeting, note, sms entries
|
||||||
|
* -------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.module_communications (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
module text NOT NULL CHECK (module IN ('courses', 'events', 'bookings')),
|
||||||
|
entity_id uuid NOT NULL,
|
||||||
|
type text NOT NULL DEFAULT 'note' CHECK (type IN ('email', 'phone', 'letter', 'meeting', 'note', 'sms')),
|
||||||
|
direction text NOT NULL DEFAULT 'internal' CHECK (direction IN ('inbound', 'outbound', 'internal')),
|
||||||
|
subject text,
|
||||||
|
body text,
|
||||||
|
email_to text,
|
||||||
|
email_cc text,
|
||||||
|
attachment_paths text[],
|
||||||
|
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_module_communications_entity
|
||||||
|
ON public.module_communications(module, entity_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_module_communications_account
|
||||||
|
ON public.module_communications(account_id, module, created_at DESC);
|
||||||
|
|
||||||
|
ALTER TABLE public.module_communications ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.module_communications FROM authenticated, service_role;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.module_communications TO authenticated;
|
||||||
|
GRANT ALL ON public.module_communications TO service_role;
|
||||||
|
|
||||||
|
CREATE POLICY module_communications_select ON public.module_communications
|
||||||
|
FOR SELECT TO authenticated USING (public.has_role_on_account(account_id));
|
||||||
|
|
||||||
|
CREATE POLICY module_communications_mutate ON public.module_communications
|
||||||
|
FOR ALL TO authenticated USING (public.has_role_on_account(account_id))
|
||||||
|
WITH CHECK (public.has_role_on_account(account_id));
|
||||||
121
apps/web/supabase/tests/database/member-audit.test.sql
Normal file
121
apps/web/supabase/tests/database/member-audit.test.sql
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
begin;
|
||||||
|
|
||||||
|
create extension "basejump-supabase_test_helpers" version '0.0.6';
|
||||||
|
|
||||||
|
select no_plan();
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- Audit Trigger & Version Tests
|
||||||
|
-- Verifies triggers fire correctly on member changes
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Setup
|
||||||
|
select tests.create_supabase_user('audit_owner', 'audit_owner@test.com');
|
||||||
|
select makerkit.set_identifier('audit_owner', 'audit_owner@test.com');
|
||||||
|
|
||||||
|
set local role service_role;
|
||||||
|
select public.create_team_account('Audit Verein', tests.get_supabase_uid('audit_owner'));
|
||||||
|
|
||||||
|
set local role postgres;
|
||||||
|
insert into public.role_permissions (role, permission)
|
||||||
|
values ('owner', 'members.write')
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
-- Get account ID
|
||||||
|
select makerkit.authenticate_as('audit_owner');
|
||||||
|
|
||||||
|
-- Insert a member (triggers audit INSERT)
|
||||||
|
set local role service_role;
|
||||||
|
insert into public.members (
|
||||||
|
account_id, first_name, last_name, status, entry_date, member_number,
|
||||||
|
created_by, updated_by
|
||||||
|
) values (
|
||||||
|
(select id from public.accounts where slug = 'audit-verein' limit 1),
|
||||||
|
'Audit', 'Test', 'active', current_date, '0001',
|
||||||
|
tests.get_supabase_uid('audit_owner'),
|
||||||
|
tests.get_supabase_uid('audit_owner')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: INSERT creates audit entry
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
select isnt_empty(
|
||||||
|
$$ select * from public.member_audit_log
|
||||||
|
where member_id = (select id from public.members where first_name = 'Audit' limit 1)
|
||||||
|
and action = 'created' $$,
|
||||||
|
'Member INSERT creates audit entry with action=created'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: Version starts at 1
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
select is(
|
||||||
|
(select version from public.members where first_name = 'Audit' limit 1),
|
||||||
|
1,
|
||||||
|
'Initial version is 1'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: UPDATE increments version
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
update public.members
|
||||||
|
set first_name = 'AuditUpdated'
|
||||||
|
where first_name = 'Audit';
|
||||||
|
|
||||||
|
select is(
|
||||||
|
(select version from public.members where first_name = 'AuditUpdated' limit 1),
|
||||||
|
2,
|
||||||
|
'Version incremented to 2 after update'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: UPDATE creates audit entry with field diff
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
select isnt_empty(
|
||||||
|
$$ select * from public.member_audit_log
|
||||||
|
where member_id = (select id from public.members where first_name = 'AuditUpdated' limit 1)
|
||||||
|
and action = 'updated'
|
||||||
|
and changes ? 'first_name' $$,
|
||||||
|
'Member UPDATE creates audit entry with first_name change diff'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: Status change creates status_changed audit entry
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
update public.members
|
||||||
|
set status = 'inactive'
|
||||||
|
where first_name = 'AuditUpdated';
|
||||||
|
|
||||||
|
select isnt_empty(
|
||||||
|
$$ select * from public.member_audit_log
|
||||||
|
where member_id = (select id from public.members where first_name = 'AuditUpdated' limit 1)
|
||||||
|
and action = 'status_changed' $$,
|
||||||
|
'Status change creates audit entry with action=status_changed'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: Archive creates archived audit entry
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
update public.members
|
||||||
|
set is_archived = true
|
||||||
|
where first_name = 'AuditUpdated';
|
||||||
|
|
||||||
|
select isnt_empty(
|
||||||
|
$$ select * from public.member_audit_log
|
||||||
|
where member_id = (select id from public.members where first_name = 'AuditUpdated' limit 1)
|
||||||
|
and action = 'archived' $$,
|
||||||
|
'Archive creates audit entry with action=archived'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: Multiple updates increment version correctly
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
select is(
|
||||||
|
(select version from public.members where first_name = 'AuditUpdated' limit 1),
|
||||||
|
4,
|
||||||
|
'Version is 4 after 3 updates (initial insert + 3 updates)'
|
||||||
|
);
|
||||||
|
|
||||||
|
select * from finish();
|
||||||
|
|
||||||
|
rollback;
|
||||||
186
apps/web/supabase/tests/database/member-constraints.test.sql
Normal file
186
apps/web/supabase/tests/database/member-constraints.test.sql
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
begin;
|
||||||
|
|
||||||
|
create extension "basejump-supabase_test_helpers" version '0.0.6';
|
||||||
|
|
||||||
|
select no_plan();
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- CHECK Constraint Tests
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Setup
|
||||||
|
select tests.create_supabase_user('constraint_owner', 'constraint_owner@test.com');
|
||||||
|
select makerkit.set_identifier('constraint_owner', 'constraint_owner@test.com');
|
||||||
|
|
||||||
|
set local role service_role;
|
||||||
|
select public.create_team_account('Constraint Verein', tests.get_supabase_uid('constraint_owner'));
|
||||||
|
|
||||||
|
set local role postgres;
|
||||||
|
insert into public.role_permissions (role, permission)
|
||||||
|
values ('owner', 'members.write')
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: DOB in future rejected
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
set local role service_role;
|
||||||
|
|
||||||
|
select throws_ok(
|
||||||
|
$test$ insert into public.members (
|
||||||
|
account_id, first_name, last_name, date_of_birth, status, entry_date, created_by, updated_by
|
||||||
|
) values (
|
||||||
|
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||||
|
'Future', 'Baby', current_date + interval '1 day', 'active', current_date,
|
||||||
|
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
|
||||||
|
) $test$,
|
||||||
|
'new row for relation "members" violates check constraint "chk_members_dob_not_future"',
|
||||||
|
'Future date of birth is rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: Exit date before entry date rejected
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
select throws_ok(
|
||||||
|
$test$ insert into public.members (
|
||||||
|
account_id, first_name, last_name, status, entry_date, exit_date, created_by, updated_by
|
||||||
|
) values (
|
||||||
|
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||||
|
'Wrong', 'Dates', 'resigned', '2024-06-01', '2024-01-01',
|
||||||
|
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
|
||||||
|
) $test$,
|
||||||
|
'new row for relation "members" violates check constraint "chk_members_exit_after_entry"',
|
||||||
|
'Exit date before entry date is rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: Entry date in future rejected
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
select throws_ok(
|
||||||
|
$test$ insert into public.members (
|
||||||
|
account_id, first_name, last_name, status, entry_date, created_by, updated_by
|
||||||
|
) values (
|
||||||
|
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||||
|
'Future', 'Entry', 'active', current_date + interval '2 days',
|
||||||
|
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
|
||||||
|
) $test$,
|
||||||
|
'new row for relation "members" violates check constraint "chk_members_entry_not_future"',
|
||||||
|
'Future entry date is rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: Valid member insert succeeds
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
select lives_ok(
|
||||||
|
$test$ insert into public.members (
|
||||||
|
account_id, first_name, last_name, status, entry_date,
|
||||||
|
date_of_birth, created_by, updated_by
|
||||||
|
) values (
|
||||||
|
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||||
|
'Valid', 'Member', 'active', '2024-01-15', '1990-05-20',
|
||||||
|
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
|
||||||
|
) $test$,
|
||||||
|
'Valid member with correct dates succeeds'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: Duplicate email in same account rejected
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
insert into public.members (
|
||||||
|
account_id, first_name, last_name, email, status, entry_date, created_by, updated_by
|
||||||
|
) values (
|
||||||
|
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||||
|
'First', 'Email', 'duplicate@test.com', 'active', current_date,
|
||||||
|
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
|
||||||
|
);
|
||||||
|
|
||||||
|
select throws_ok(
|
||||||
|
$test$ insert into public.members (
|
||||||
|
account_id, first_name, last_name, email, status, entry_date, created_by, updated_by
|
||||||
|
) values (
|
||||||
|
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||||
|
'Second', 'Email', 'duplicate@test.com', 'active', current_date,
|
||||||
|
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
|
||||||
|
) $test$,
|
||||||
|
'duplicate key value violates unique constraint "uix_members_email_per_account"',
|
||||||
|
'Duplicate email in same account is rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: NULL emails allowed (multiple)
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
select lives_ok(
|
||||||
|
$test$ insert into public.members (
|
||||||
|
account_id, first_name, last_name, email, status, entry_date, created_by, updated_by
|
||||||
|
) values (
|
||||||
|
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||||
|
'No', 'Email1', null, 'active', current_date,
|
||||||
|
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
|
||||||
|
) $test$,
|
||||||
|
'NULL email is allowed'
|
||||||
|
);
|
||||||
|
|
||||||
|
select lives_ok(
|
||||||
|
$test$ insert into public.members (
|
||||||
|
account_id, first_name, last_name, email, status, entry_date, created_by, updated_by
|
||||||
|
) values (
|
||||||
|
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||||
|
'No', 'Email2', null, 'active', current_date,
|
||||||
|
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
|
||||||
|
) $test$,
|
||||||
|
'Multiple NULL emails allowed'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: Invalid IBAN rejected on sepa_mandates
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
insert into public.members (
|
||||||
|
account_id, first_name, last_name, status, entry_date, member_number, created_by, updated_by
|
||||||
|
) values (
|
||||||
|
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||||
|
'SEPA', 'Test', 'active', current_date, 'SEPA01',
|
||||||
|
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
|
||||||
|
);
|
||||||
|
|
||||||
|
select throws_ok(
|
||||||
|
$test$ insert into public.sepa_mandates (
|
||||||
|
member_id, account_id, mandate_reference, iban, account_holder, mandate_date, status
|
||||||
|
) values (
|
||||||
|
(select id from public.members where first_name = 'SEPA' limit 1),
|
||||||
|
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||||
|
'MANDATE-001', 'invalid-iban', 'Test Holder', current_date, 'active'
|
||||||
|
) $test$,
|
||||||
|
'new row for relation "sepa_mandates" violates check constraint "chk_sepa_iban_format"',
|
||||||
|
'Invalid IBAN format is rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: Valid IBAN accepted
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
select lives_ok(
|
||||||
|
$test$ insert into public.sepa_mandates (
|
||||||
|
member_id, account_id, mandate_reference, iban, account_holder, mandate_date, status
|
||||||
|
) values (
|
||||||
|
(select id from public.members where first_name = 'SEPA' limit 1),
|
||||||
|
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||||
|
'MANDATE-002', 'DE89370400440532013000', 'Test Holder', current_date, 'active'
|
||||||
|
) $test$,
|
||||||
|
'Valid German IBAN is accepted'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: Negative dues amount rejected
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
select throws_ok(
|
||||||
|
$test$ insert into public.dues_categories (
|
||||||
|
account_id, name, amount
|
||||||
|
) values (
|
||||||
|
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||||
|
'Negative Fee', -50
|
||||||
|
) $test$,
|
||||||
|
'new row for relation "dues_categories" violates check constraint "chk_dues_amount_non_negative"',
|
||||||
|
'Negative dues amount is rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
select * from finish();
|
||||||
|
|
||||||
|
rollback;
|
||||||
211
apps/web/supabase/tests/database/member-functions.test.sql
Normal file
211
apps/web/supabase/tests/database/member-functions.test.sql
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
begin;
|
||||||
|
|
||||||
|
create extension "basejump-supabase_test_helpers" version '0.0.6';
|
||||||
|
|
||||||
|
select no_plan();
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- Member Management Function Tests
|
||||||
|
-- Tests PG functions for correctness, auth, atomicity
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Setup: create test users and account
|
||||||
|
select tests.create_supabase_user('owner', 'owner@test.com');
|
||||||
|
select tests.create_supabase_user('member_user', 'member@test.com');
|
||||||
|
select tests.create_supabase_user('outsider', 'outsider@test.com');
|
||||||
|
|
||||||
|
select makerkit.set_identifier('owner', 'owner@test.com');
|
||||||
|
select makerkit.set_identifier('member_user', 'member@test.com');
|
||||||
|
select makerkit.set_identifier('outsider', 'outsider@test.com');
|
||||||
|
|
||||||
|
-- Create a team account owned by 'owner'
|
||||||
|
set local role service_role;
|
||||||
|
select public.create_team_account('Test Verein', tests.get_supabase_uid('owner'));
|
||||||
|
|
||||||
|
-- Get account ID
|
||||||
|
select makerkit.authenticate_as('owner');
|
||||||
|
\set test_account_id '(select id from public.accounts where slug = ''test-verein'' limit 1)'
|
||||||
|
|
||||||
|
-- Grant members.write permission to owner
|
||||||
|
set local role postgres;
|
||||||
|
insert into public.role_permissions (role, permission)
|
||||||
|
values ('owner', 'members.write')
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: get_next_member_number
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
select makerkit.authenticate_as('owner');
|
||||||
|
|
||||||
|
select is(
|
||||||
|
public.get_next_member_number(:test_account_id),
|
||||||
|
'0001',
|
||||||
|
'First member number should be 0001'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert a member to test incrementing
|
||||||
|
set local role service_role;
|
||||||
|
insert into public.members (account_id, first_name, last_name, member_number, status, entry_date, created_by, updated_by)
|
||||||
|
values (:test_account_id, 'Max', 'Mustermann', '0001', 'active', current_date,
|
||||||
|
tests.get_supabase_uid('owner'), tests.get_supabase_uid('owner'));
|
||||||
|
|
||||||
|
select makerkit.authenticate_as('owner');
|
||||||
|
|
||||||
|
select is(
|
||||||
|
public.get_next_member_number(:test_account_id),
|
||||||
|
'0002',
|
||||||
|
'Second member number should be 0002'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: get_member_quick_stats
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
select isnt_empty(
|
||||||
|
$$ select * from public.get_member_quick_stats((select id from public.accounts where slug = 'test-verein' limit 1)) $$,
|
||||||
|
'Quick stats returns data for account with members'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: check_duplicate_member
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
select isnt_empty(
|
||||||
|
$$ select * from public.check_duplicate_member(
|
||||||
|
(select id from public.accounts where slug = 'test-verein' limit 1),
|
||||||
|
'Max', 'Mustermann', null
|
||||||
|
) $$,
|
||||||
|
'Duplicate check finds existing member by name'
|
||||||
|
);
|
||||||
|
|
||||||
|
select is_empty(
|
||||||
|
$$ select * from public.check_duplicate_member(
|
||||||
|
(select id from public.accounts where slug = 'test-verein' limit 1),
|
||||||
|
'Nonexistent', 'Person', null
|
||||||
|
) $$,
|
||||||
|
'Duplicate check returns empty for non-matching name'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: approve_application
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
-- Create a test application
|
||||||
|
set local role service_role;
|
||||||
|
insert into public.membership_applications (
|
||||||
|
account_id, first_name, last_name, email, status
|
||||||
|
) values (
|
||||||
|
:test_account_id, 'Anna', 'Bewerberin', 'anna@test.com', 'submitted'
|
||||||
|
);
|
||||||
|
|
||||||
|
select makerkit.authenticate_as('owner');
|
||||||
|
|
||||||
|
-- Approve it
|
||||||
|
select lives_ok(
|
||||||
|
$$ select public.approve_application(
|
||||||
|
(select id from public.membership_applications where email = 'anna@test.com'),
|
||||||
|
tests.get_supabase_uid('owner')
|
||||||
|
) $$,
|
||||||
|
'Owner can approve application'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Verify member was created
|
||||||
|
select isnt_empty(
|
||||||
|
$$ select * from public.members where first_name = 'Anna' and last_name = 'Bewerberin' $$,
|
||||||
|
'Approved application creates a member'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Verify application status changed
|
||||||
|
select is(
|
||||||
|
(select status from public.membership_applications where email = 'anna@test.com'),
|
||||||
|
'approved'::public.application_status,
|
||||||
|
'Application status is approved'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: reject_application
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
set local role service_role;
|
||||||
|
insert into public.membership_applications (
|
||||||
|
account_id, first_name, last_name, email, status
|
||||||
|
) values (
|
||||||
|
:test_account_id, 'Bob', 'Abgelehnt', 'bob@test.com', 'submitted'
|
||||||
|
);
|
||||||
|
|
||||||
|
select makerkit.authenticate_as('owner');
|
||||||
|
|
||||||
|
select lives_ok(
|
||||||
|
$$ select public.reject_application(
|
||||||
|
(select id from public.membership_applications where email = 'bob@test.com'),
|
||||||
|
tests.get_supabase_uid('owner'),
|
||||||
|
'Nicht qualifiziert'
|
||||||
|
) $$,
|
||||||
|
'Owner can reject application'
|
||||||
|
);
|
||||||
|
|
||||||
|
select is(
|
||||||
|
(select status from public.membership_applications where email = 'bob@test.com'),
|
||||||
|
'rejected'::public.application_status,
|
||||||
|
'Application status is rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: approve_application — already approved should fail
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Verify the re-approval throws with status message
|
||||||
|
prepare approve_again as select public.approve_application(
|
||||||
|
(select id from public.membership_applications where email = 'anna@test.com'),
|
||||||
|
tests.get_supabase_uid('owner')
|
||||||
|
);
|
||||||
|
select throws_ok(
|
||||||
|
'approve_again',
|
||||||
|
'P0001',
|
||||||
|
'Application is not in a reviewable state (current: approved)',
|
||||||
|
'Cannot approve already-approved application'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: get_member_timeline
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- The member creation via approve_application should have generated an audit entry
|
||||||
|
select isnt_empty(
|
||||||
|
$$ select * from public.get_member_timeline(
|
||||||
|
(select id from public.members where first_name = 'Anna' limit 1),
|
||||||
|
1, 50, null
|
||||||
|
) $$,
|
||||||
|
'Member timeline has entries after creation'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: log_member_audit_event
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
select makerkit.authenticate_as('owner');
|
||||||
|
|
||||||
|
select lives_ok(
|
||||||
|
$$ select public.log_member_audit_event(
|
||||||
|
(select id from public.members where first_name = 'Max' limit 1),
|
||||||
|
(select id from public.accounts where slug = 'test-verein' limit 1),
|
||||||
|
'note_added',
|
||||||
|
'{"note": "Test note"}'::jsonb,
|
||||||
|
'{}'::jsonb
|
||||||
|
) $$,
|
||||||
|
'Owner can log audit event for member'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Test: outsider cannot access functions
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
select makerkit.authenticate_as('outsider');
|
||||||
|
|
||||||
|
-- Outsider should get an error when calling get_next_member_number
|
||||||
|
prepare outsider_member_number as select public.get_next_member_number(
|
||||||
|
(select id from public.accounts where slug = 'test-verein' limit 1)
|
||||||
|
);
|
||||||
|
select throws_ok(
|
||||||
|
'outsider_member_number',
|
||||||
|
'P0001',
|
||||||
|
null,
|
||||||
|
'Outsider cannot call get_next_member_number'
|
||||||
|
);
|
||||||
|
|
||||||
|
select * from finish();
|
||||||
|
|
||||||
|
rollback;
|
||||||
105
apps/web/supabase/tests/database/member-tables.test.sql
Normal file
105
apps/web/supabase/tests/database/member-tables.test.sql
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
begin;
|
||||||
|
|
||||||
|
create extension "basejump-supabase_test_helpers" version '0.0.6';
|
||||||
|
|
||||||
|
select no_plan();
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- Member Management Schema Tests
|
||||||
|
-- Verifies all tables, columns, and RLS settings
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- 1. Core tables exist
|
||||||
|
select has_table('public', 'members', 'members table exists');
|
||||||
|
select has_table('public', 'dues_categories', 'dues_categories table exists');
|
||||||
|
select has_table('public', 'membership_applications', 'membership_applications table exists');
|
||||||
|
select has_table('public', 'member_cards', 'member_cards table exists');
|
||||||
|
select has_table('public', 'member_departments', 'member_departments table exists');
|
||||||
|
select has_table('public', 'member_department_assignments', 'member_department_assignments table exists');
|
||||||
|
select has_table('public', 'member_roles', 'member_roles table exists');
|
||||||
|
select has_table('public', 'member_honors', 'member_honors table exists');
|
||||||
|
select has_table('public', 'sepa_mandates', 'sepa_mandates table exists');
|
||||||
|
select has_table('public', 'member_portal_invitations', 'member_portal_invitations table exists');
|
||||||
|
select has_table('public', 'member_transfers', 'member_transfers table exists');
|
||||||
|
|
||||||
|
-- 2. New Phase 1-4 tables exist
|
||||||
|
select has_table('public', 'member_audit_log', 'member_audit_log table exists');
|
||||||
|
select has_table('public', 'member_communications', 'member_communications table exists');
|
||||||
|
select has_table('public', 'member_tags', 'member_tags table exists');
|
||||||
|
select has_table('public', 'member_tag_assignments', 'member_tag_assignments table exists');
|
||||||
|
select has_table('public', 'member_merges', 'member_merges table exists');
|
||||||
|
select has_table('public', 'gdpr_retention_policies', 'gdpr_retention_policies table exists');
|
||||||
|
select has_table('public', 'member_notification_rules', 'member_notification_rules table exists');
|
||||||
|
select has_table('public', 'scheduled_job_configs', 'scheduled_job_configs table exists');
|
||||||
|
select has_table('public', 'scheduled_job_runs', 'scheduled_job_runs table exists');
|
||||||
|
select has_table('public', 'pending_member_notifications', 'pending_member_notifications table exists');
|
||||||
|
|
||||||
|
-- 3. New columns on members table
|
||||||
|
select has_column('public', 'members', 'primary_mandate_id', 'members has primary_mandate_id column');
|
||||||
|
select has_column('public', 'members', 'version', 'members has version column');
|
||||||
|
|
||||||
|
-- 4. New column on event_registrations
|
||||||
|
select has_column('public', 'event_registrations', 'member_id', 'event_registrations has member_id FK');
|
||||||
|
|
||||||
|
-- 5. RLS enabled on all member tables
|
||||||
|
select is(
|
||||||
|
(select relrowsecurity from pg_class where relname = 'members' and relnamespace = 'public'::regnamespace),
|
||||||
|
true, 'RLS enabled on members'
|
||||||
|
);
|
||||||
|
select is(
|
||||||
|
(select relrowsecurity from pg_class where relname = 'member_audit_log' and relnamespace = 'public'::regnamespace),
|
||||||
|
true, 'RLS enabled on member_audit_log'
|
||||||
|
);
|
||||||
|
select is(
|
||||||
|
(select relrowsecurity from pg_class where relname = 'member_communications' and relnamespace = 'public'::regnamespace),
|
||||||
|
true, 'RLS enabled on member_communications'
|
||||||
|
);
|
||||||
|
select is(
|
||||||
|
(select relrowsecurity from pg_class where relname = 'member_tags' and relnamespace = 'public'::regnamespace),
|
||||||
|
true, 'RLS enabled on member_tags'
|
||||||
|
);
|
||||||
|
select is(
|
||||||
|
(select relrowsecurity from pg_class where relname = 'member_tag_assignments' and relnamespace = 'public'::regnamespace),
|
||||||
|
true, 'RLS enabled on member_tag_assignments'
|
||||||
|
);
|
||||||
|
select is(
|
||||||
|
(select relrowsecurity from pg_class where relname = 'member_notification_rules' and relnamespace = 'public'::regnamespace),
|
||||||
|
true, 'RLS enabled on member_notification_rules'
|
||||||
|
);
|
||||||
|
select is(
|
||||||
|
(select relrowsecurity from pg_class where relname = 'scheduled_job_configs' and relnamespace = 'public'::regnamespace),
|
||||||
|
true, 'RLS enabled on scheduled_job_configs'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 6. Key indexes exist
|
||||||
|
select is(
|
||||||
|
(select count(*) > 0 from pg_indexes where tablename = 'members' and indexname = 'ix_members_active_account_status'),
|
||||||
|
true, 'Active members composite index exists'
|
||||||
|
);
|
||||||
|
select is(
|
||||||
|
(select count(*) > 0 from pg_indexes where tablename = 'member_audit_log' and indexname = 'ix_member_audit_member'),
|
||||||
|
true, 'Audit log member index exists'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 7. Check constraints exist on members
|
||||||
|
select is(
|
||||||
|
(select count(*) > 0 from information_schema.check_constraints
|
||||||
|
where constraint_name = 'chk_members_dob_not_future'),
|
||||||
|
true, 'DOB not-future constraint exists'
|
||||||
|
);
|
||||||
|
select is(
|
||||||
|
(select count(*) > 0 from information_schema.check_constraints
|
||||||
|
where constraint_name = 'chk_members_exit_after_entry'),
|
||||||
|
true, 'Exit-after-entry constraint exists'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 8. Version column has correct default
|
||||||
|
select is(
|
||||||
|
(select column_default from information_schema.columns
|
||||||
|
where table_name = 'members' and column_name = 'version'),
|
||||||
|
'1', 'Version column defaults to 1'
|
||||||
|
);
|
||||||
|
|
||||||
|
select * from finish();
|
||||||
|
|
||||||
|
rollback;
|
||||||
@@ -333,6 +333,8 @@ services:
|
|||||||
# Stripe (build-time)
|
# Stripe (build-time)
|
||||||
NEXT_PUBLIC_BILLING_PROVIDER: stripe
|
NEXT_PUBLIC_BILLING_PROVIDER: stripe
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
|
||||||
|
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING: 'true'
|
||||||
|
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING: 'true'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
supabase-kong:
|
supabase-kong:
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ $PSQL -c "
|
|||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fish_stocking TO authenticated;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fish_stocking TO authenticated;
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_leases TO authenticated;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_leases TO authenticated;
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.catch_books TO authenticated;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.catch_books TO authenticated;
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.catch_entries TO authenticated;
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_permits TO authenticated;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_permits TO authenticated;
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_competitions TO authenticated;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_competitions TO authenticated;
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_clubs TO authenticated;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_clubs TO authenticated;
|
||||||
|
|||||||
@@ -35,5 +35,8 @@
|
|||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-hook-form": "catalog:",
|
"react-hook-form": "catalog:",
|
||||||
"zod": "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 { CreateBookingForm } from './create-booking-form';
|
||||||
|
export { BookingStatusActions } from './booking-status-actions';
|
||||||
|
export { CreateRoomDialog } from './create-room-dialog';
|
||||||
|
export { CreateGuestDialog } from './create-guest-dialog';
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
interface CommunicationListOptions {
|
||||||
|
type?: string;
|
||||||
|
direction?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateCommunicationInput {
|
||||||
|
accountId: string;
|
||||||
|
entityId: string;
|
||||||
|
type: string;
|
||||||
|
direction?: string;
|
||||||
|
subject?: string;
|
||||||
|
body?: string;
|
||||||
|
emailTo?: string;
|
||||||
|
emailCc?: string;
|
||||||
|
attachmentPaths?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBookingCommunicationService(
|
||||||
|
client: SupabaseClient<Database>,
|
||||||
|
) {
|
||||||
|
return new BookingCommunicationService(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
class BookingCommunicationService {
|
||||||
|
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
bookingId: string,
|
||||||
|
accountId: string,
|
||||||
|
opts?: CommunicationListOptions,
|
||||||
|
) {
|
||||||
|
let query = (this.client.from as CallableFunction)('module_communications')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.eq('module', 'bookings')
|
||||||
|
.eq('entity_id', bookingId)
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (opts?.type) query = query.eq('type', opts.type);
|
||||||
|
if (opts?.direction) query = query.eq('direction', opts.direction);
|
||||||
|
if (opts?.search) {
|
||||||
|
const escaped = opts.search.replace(/[%_\\]/g, '\\$&');
|
||||||
|
query = query.or(`subject.ilike.%${escaped}%,body.ilike.%${escaped}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = opts?.page ?? 1;
|
||||||
|
const pageSize = opts?.pageSize ?? 25;
|
||||||
|
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
||||||
|
|
||||||
|
const { data, error, count } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
return { data: data ?? [], total: count ?? 0, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateCommunicationInput, userId: string) {
|
||||||
|
const { data, error } = await (this.client.from as CallableFunction)(
|
||||||
|
'module_communications',
|
||||||
|
)
|
||||||
|
.insert({
|
||||||
|
account_id: input.accountId,
|
||||||
|
module: 'bookings',
|
||||||
|
entity_id: input.entityId,
|
||||||
|
type: input.type,
|
||||||
|
direction: input.direction ?? 'internal',
|
||||||
|
subject: input.subject ?? null,
|
||||||
|
body: input.body ?? null,
|
||||||
|
email_to: input.emailTo ?? null,
|
||||||
|
email_cc: input.emailCc ?? null,
|
||||||
|
attachment_paths: input.attachmentPaths ?? null,
|
||||||
|
created_by: userId,
|
||||||
|
})
|
||||||
|
.select(
|
||||||
|
'id, account_id, module, entity_id, type, direction, subject, email_to, created_at, created_by',
|
||||||
|
)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(communicationId: string) {
|
||||||
|
const { error } = await (this.client.from as CallableFunction)(
|
||||||
|
'module_communications',
|
||||||
|
)
|
||||||
|
.delete()
|
||||||
|
.eq('id', communicationId)
|
||||||
|
.eq('module', 'bookings');
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
export function createBookingExportService(client: SupabaseClient<Database>) {
|
||||||
|
return new BookingExportService(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
class BookingExportService {
|
||||||
|
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||||
|
|
||||||
|
async exportBookingsCsv(
|
||||||
|
accountId: string,
|
||||||
|
filters?: { status?: string },
|
||||||
|
): Promise<string> {
|
||||||
|
let query = this.client
|
||||||
|
.from('bookings')
|
||||||
|
.select('*, rooms(room_number, name), guests(first_name, last_name)')
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.order('check_in', { ascending: false });
|
||||||
|
|
||||||
|
if (filters?.status) {
|
||||||
|
query = query.eq('status', filters.status as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: bookings, error } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
if (!bookings?.length) return '';
|
||||||
|
|
||||||
|
const headers = ['Zimmer', 'Gast', 'Anreise', 'Abreise', 'Status', 'Preis'];
|
||||||
|
|
||||||
|
const rows = bookings.map((b) => {
|
||||||
|
const room = (b as any).rooms;
|
||||||
|
const guest = (b as any).guests;
|
||||||
|
const roomLabel = room
|
||||||
|
? `${room.room_number}${room.name ? ` (${room.name})` : ''}`
|
||||||
|
: '';
|
||||||
|
const guestLabel = guest ? `${guest.first_name} ${guest.last_name}` : '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
roomLabel,
|
||||||
|
guestLabel,
|
||||||
|
b.check_in ?? '',
|
||||||
|
b.check_out ?? '',
|
||||||
|
b.status,
|
||||||
|
b.total_price?.toString() ?? '0',
|
||||||
|
]
|
||||||
|
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||||
|
.join(';');
|
||||||
|
});
|
||||||
|
|
||||||
|
return [headers.join(';'), ...rows].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportGuestsCsv(accountId: string): Promise<string> {
|
||||||
|
const { data: guests, error } = await this.client
|
||||||
|
.from('guests')
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.order('last_name');
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
if (!guests?.length) return '';
|
||||||
|
|
||||||
|
const headers = ['Vorname', 'Nachname', 'E-Mail', 'Telefon', 'Ort'];
|
||||||
|
|
||||||
|
const rows = guests.map((g) =>
|
||||||
|
[g.first_name, g.last_name, g.email ?? '', g.phone ?? '', g.city ?? '']
|
||||||
|
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||||
|
.join(';'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [headers.join(';'), ...rows].join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
const NAMESPACE = 'booking-notification';
|
||||||
|
const MODULE = 'bookings';
|
||||||
|
|
||||||
|
interface NotificationRule {
|
||||||
|
id: string;
|
||||||
|
channel: 'in_app' | 'email' | 'both';
|
||||||
|
recipient_type: string;
|
||||||
|
recipient_config: Record<string, unknown>;
|
||||||
|
subject_template: string | null;
|
||||||
|
message_template: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBookingNotificationService(
|
||||||
|
client: SupabaseClient<Database>,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
async enqueue(
|
||||||
|
accountId: string,
|
||||||
|
triggerEvent: string,
|
||||||
|
entityId: string,
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
await (client.rpc as CallableFunction)('enqueue_module_notification', {
|
||||||
|
p_account_id: accountId,
|
||||||
|
p_module: MODULE,
|
||||||
|
p_trigger_event: triggerEvent,
|
||||||
|
p_entity_id: entityId,
|
||||||
|
p_context: context,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async processPending(): Promise<{ processed: number; sent: number }> {
|
||||||
|
const logger = await getLogger();
|
||||||
|
|
||||||
|
const { data: pending, error } = await (client.from as CallableFunction)(
|
||||||
|
'pending_module_notifications',
|
||||||
|
)
|
||||||
|
.select('*')
|
||||||
|
.eq('module', MODULE)
|
||||||
|
.is('processed_at', null)
|
||||||
|
.order('created_at')
|
||||||
|
.limit(100);
|
||||||
|
|
||||||
|
if (error || !pending?.length) return { processed: 0, sent: 0 };
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
|
||||||
|
for (const n of pending as Array<{
|
||||||
|
id: number;
|
||||||
|
account_id: string;
|
||||||
|
trigger_event: string;
|
||||||
|
context: Record<string, unknown>;
|
||||||
|
}>) {
|
||||||
|
try {
|
||||||
|
sent += await this.dispatch(
|
||||||
|
n.account_id,
|
||||||
|
n.trigger_event,
|
||||||
|
n.context ?? {},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
{ name: NAMESPACE, id: n.id, error: e },
|
||||||
|
'Dispatch failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await (client.from as CallableFunction)('pending_module_notifications')
|
||||||
|
.update({ processed_at: new Date().toISOString() })
|
||||||
|
.eq('id', n.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ name: NAMESPACE, processed: pending.length, sent },
|
||||||
|
'Batch processed',
|
||||||
|
);
|
||||||
|
return { processed: pending.length, sent };
|
||||||
|
},
|
||||||
|
|
||||||
|
async dispatch(
|
||||||
|
accountId: string,
|
||||||
|
triggerEvent: string,
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
): Promise<number> {
|
||||||
|
const { data: rules } = await (client.from as CallableFunction)(
|
||||||
|
'module_notification_rules',
|
||||||
|
)
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.eq('module', MODULE)
|
||||||
|
.eq('trigger_event', triggerEvent)
|
||||||
|
.eq('is_active', true);
|
||||||
|
|
||||||
|
if (!rules?.length) return 0;
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
|
||||||
|
for (const rule of rules as NotificationRule[]) {
|
||||||
|
const message = renderTemplate(rule.message_template, context);
|
||||||
|
|
||||||
|
if (rule.channel === 'in_app' || rule.channel === 'both') {
|
||||||
|
const { createNotificationsApi } =
|
||||||
|
await import('@kit/notifications/api');
|
||||||
|
const api = createNotificationsApi(client);
|
||||||
|
await api.createNotification({
|
||||||
|
account_id: accountId,
|
||||||
|
body: message,
|
||||||
|
type: 'info',
|
||||||
|
channel: 'in_app',
|
||||||
|
});
|
||||||
|
sent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.channel === 'email' || rule.channel === 'both') {
|
||||||
|
const subject = rule.subject_template
|
||||||
|
? renderTemplate(rule.subject_template, context)
|
||||||
|
: triggerEvent;
|
||||||
|
const email = context.email as string | undefined;
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
const { getMailer } = await import('@kit/mailers');
|
||||||
|
const mailer = await getMailer();
|
||||||
|
await mailer.sendEmail({
|
||||||
|
to: email,
|
||||||
|
from: process.env.EMAIL_SENDER ?? 'noreply@example.com',
|
||||||
|
subject,
|
||||||
|
html: `<div style="font-family:sans-serif;max-width:600px;margin:0 auto">${message}</div>`,
|
||||||
|
});
|
||||||
|
sent++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sent;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplate(
|
||||||
|
template: string,
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
): string {
|
||||||
|
let result = template;
|
||||||
|
for (const [key, value] of Object.entries(context)) {
|
||||||
|
result = result.replace(
|
||||||
|
new RegExp(`\\{\\{${key}\\}\\}`, 'g'),
|
||||||
|
String(value ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -35,5 +35,8 @@
|
|||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-hook-form": "catalog:",
|
"react-hook-form": "catalog:",
|
||||||
"zod": "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 { 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');
|
logger.info({ name: 'course.createSession' }, 'Session created');
|
||||||
return { success: true, data: result };
|
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 };
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
interface CommunicationListOptions {
|
||||||
|
type?: string;
|
||||||
|
direction?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateCommunicationInput {
|
||||||
|
accountId: string;
|
||||||
|
entityId: string;
|
||||||
|
type: string;
|
||||||
|
direction?: string;
|
||||||
|
subject?: string;
|
||||||
|
body?: string;
|
||||||
|
emailTo?: string;
|
||||||
|
emailCc?: string;
|
||||||
|
attachmentPaths?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCourseCommunicationService(
|
||||||
|
client: SupabaseClient<Database>,
|
||||||
|
) {
|
||||||
|
return new CourseCommunicationService(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
class CourseCommunicationService {
|
||||||
|
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
courseId: string,
|
||||||
|
accountId: string,
|
||||||
|
opts?: CommunicationListOptions,
|
||||||
|
) {
|
||||||
|
let query = (this.client.from as CallableFunction)('module_communications')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.eq('module', 'courses')
|
||||||
|
.eq('entity_id', courseId)
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (opts?.type) query = query.eq('type', opts.type);
|
||||||
|
if (opts?.direction) query = query.eq('direction', opts.direction);
|
||||||
|
if (opts?.search) {
|
||||||
|
const escaped = opts.search.replace(/[%_\\]/g, '\\$&');
|
||||||
|
query = query.or(`subject.ilike.%${escaped}%,body.ilike.%${escaped}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = opts?.page ?? 1;
|
||||||
|
const pageSize = opts?.pageSize ?? 25;
|
||||||
|
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
||||||
|
|
||||||
|
const { data, error, count } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
return { data: data ?? [], total: count ?? 0, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateCommunicationInput, userId: string) {
|
||||||
|
const { data, error } = await (this.client.from as CallableFunction)(
|
||||||
|
'module_communications',
|
||||||
|
)
|
||||||
|
.insert({
|
||||||
|
account_id: input.accountId,
|
||||||
|
module: 'courses',
|
||||||
|
entity_id: input.entityId,
|
||||||
|
type: input.type,
|
||||||
|
direction: input.direction ?? 'internal',
|
||||||
|
subject: input.subject ?? null,
|
||||||
|
body: input.body ?? null,
|
||||||
|
email_to: input.emailTo ?? null,
|
||||||
|
email_cc: input.emailCc ?? null,
|
||||||
|
attachment_paths: input.attachmentPaths ?? null,
|
||||||
|
created_by: userId,
|
||||||
|
})
|
||||||
|
.select(
|
||||||
|
'id, account_id, module, entity_id, type, direction, subject, email_to, created_at, created_by',
|
||||||
|
)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(communicationId: string) {
|
||||||
|
const { error } = await (this.client.from as CallableFunction)(
|
||||||
|
'module_communications',
|
||||||
|
)
|
||||||
|
.delete()
|
||||||
|
.eq('id', communicationId)
|
||||||
|
.eq('module', 'courses');
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
export function createCourseExportService(client: SupabaseClient<Database>) {
|
||||||
|
return new CourseExportService(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
class CourseExportService {
|
||||||
|
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||||
|
|
||||||
|
async exportParticipantsCsv(courseId: string): Promise<string> {
|
||||||
|
const { data: participants, error } = await this.client
|
||||||
|
.from('course_participants')
|
||||||
|
.select('*')
|
||||||
|
.eq('course_id', courseId)
|
||||||
|
.order('last_name');
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
if (!participants?.length) return '';
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
'Vorname',
|
||||||
|
'Nachname',
|
||||||
|
'E-Mail',
|
||||||
|
'Telefon',
|
||||||
|
'Status',
|
||||||
|
'Anmeldedatum',
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = participants.map((p) =>
|
||||||
|
[
|
||||||
|
p.first_name,
|
||||||
|
p.last_name,
|
||||||
|
p.email ?? '',
|
||||||
|
p.phone ?? '',
|
||||||
|
p.status,
|
||||||
|
p.enrolled_at ?? '',
|
||||||
|
]
|
||||||
|
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||||
|
.join(';'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [headers.join(';'), ...rows].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportCoursesCsv(
|
||||||
|
accountId: string,
|
||||||
|
filters?: { status?: string },
|
||||||
|
): Promise<string> {
|
||||||
|
let query = this.client
|
||||||
|
.from('courses')
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.order('start_date', { ascending: false });
|
||||||
|
|
||||||
|
if (filters?.status) {
|
||||||
|
query = query.eq('status', filters.status as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: courses, error } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
if (!courses?.length) return '';
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
'Kursnr.',
|
||||||
|
'Name',
|
||||||
|
'Status',
|
||||||
|
'Startdatum',
|
||||||
|
'Enddatum',
|
||||||
|
'Gebuhr',
|
||||||
|
'Kapazitat',
|
||||||
|
'Min. Teilnehmer',
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = courses.map((c) =>
|
||||||
|
[
|
||||||
|
c.course_number ?? '',
|
||||||
|
c.name,
|
||||||
|
c.status,
|
||||||
|
c.start_date ?? '',
|
||||||
|
c.end_date ?? '',
|
||||||
|
c.fee?.toString() ?? '0',
|
||||||
|
c.capacity?.toString() ?? '',
|
||||||
|
c.min_participants?.toString() ?? '',
|
||||||
|
]
|
||||||
|
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||||
|
.join(';'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [headers.join(';'), ...rows].join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
const NAMESPACE = 'course-notification';
|
||||||
|
const MODULE = 'courses';
|
||||||
|
|
||||||
|
interface NotificationRule {
|
||||||
|
id: string;
|
||||||
|
channel: 'in_app' | 'email' | 'both';
|
||||||
|
recipient_type: string;
|
||||||
|
recipient_config: Record<string, unknown>;
|
||||||
|
subject_template: string | null;
|
||||||
|
message_template: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCourseNotificationService(
|
||||||
|
client: SupabaseClient<Database>,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
async enqueue(
|
||||||
|
accountId: string,
|
||||||
|
triggerEvent: string,
|
||||||
|
entityId: string,
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
await (client.rpc as CallableFunction)('enqueue_module_notification', {
|
||||||
|
p_account_id: accountId,
|
||||||
|
p_module: MODULE,
|
||||||
|
p_trigger_event: triggerEvent,
|
||||||
|
p_entity_id: entityId,
|
||||||
|
p_context: context,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async processPending(): Promise<{ processed: number; sent: number }> {
|
||||||
|
const logger = await getLogger();
|
||||||
|
|
||||||
|
const { data: pending, error } = await (client.from as CallableFunction)(
|
||||||
|
'pending_module_notifications',
|
||||||
|
)
|
||||||
|
.select('*')
|
||||||
|
.eq('module', MODULE)
|
||||||
|
.is('processed_at', null)
|
||||||
|
.order('created_at')
|
||||||
|
.limit(100);
|
||||||
|
|
||||||
|
if (error || !pending?.length) return { processed: 0, sent: 0 };
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
|
||||||
|
for (const n of pending as Array<{
|
||||||
|
id: number;
|
||||||
|
account_id: string;
|
||||||
|
trigger_event: string;
|
||||||
|
context: Record<string, unknown>;
|
||||||
|
}>) {
|
||||||
|
try {
|
||||||
|
sent += await this.dispatch(
|
||||||
|
n.account_id,
|
||||||
|
n.trigger_event,
|
||||||
|
n.context ?? {},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
{ name: NAMESPACE, id: n.id, error: e },
|
||||||
|
'Dispatch failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await (client.from as CallableFunction)('pending_module_notifications')
|
||||||
|
.update({ processed_at: new Date().toISOString() })
|
||||||
|
.eq('id', n.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ name: NAMESPACE, processed: pending.length, sent },
|
||||||
|
'Batch processed',
|
||||||
|
);
|
||||||
|
return { processed: pending.length, sent };
|
||||||
|
},
|
||||||
|
|
||||||
|
async dispatch(
|
||||||
|
accountId: string,
|
||||||
|
triggerEvent: string,
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
): Promise<number> {
|
||||||
|
const { data: rules } = await (client.from as CallableFunction)(
|
||||||
|
'module_notification_rules',
|
||||||
|
)
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.eq('module', MODULE)
|
||||||
|
.eq('trigger_event', triggerEvent)
|
||||||
|
.eq('is_active', true);
|
||||||
|
|
||||||
|
if (!rules?.length) return 0;
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
|
||||||
|
for (const rule of rules as NotificationRule[]) {
|
||||||
|
const message = renderTemplate(rule.message_template, context);
|
||||||
|
|
||||||
|
if (rule.channel === 'in_app' || rule.channel === 'both') {
|
||||||
|
const { createNotificationsApi } =
|
||||||
|
await import('@kit/notifications/api');
|
||||||
|
const api = createNotificationsApi(client);
|
||||||
|
await api.createNotification({
|
||||||
|
account_id: accountId,
|
||||||
|
body: message,
|
||||||
|
type: 'info',
|
||||||
|
channel: 'in_app',
|
||||||
|
});
|
||||||
|
sent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.channel === 'email' || rule.channel === 'both') {
|
||||||
|
const subject = rule.subject_template
|
||||||
|
? renderTemplate(rule.subject_template, context)
|
||||||
|
: triggerEvent;
|
||||||
|
const email = context.email as string | undefined;
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
const { getMailer } = await import('@kit/mailers');
|
||||||
|
const mailer = await getMailer();
|
||||||
|
await mailer.sendEmail({
|
||||||
|
to: email,
|
||||||
|
from: process.env.EMAIL_SENDER ?? 'noreply@example.com',
|
||||||
|
subject,
|
||||||
|
html: `<div style="font-family:sans-serif;max-width:600px;margin:0 auto">${message}</div>`,
|
||||||
|
});
|
||||||
|
sent++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sent;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplate(
|
||||||
|
template: string,
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
): string {
|
||||||
|
let result = template;
|
||||||
|
for (const [key, value] of Object.entries(context)) {
|
||||||
|
result = result.replace(
|
||||||
|
new RegExp(`\\{\\{${key}\\}\\}`, 'g'),
|
||||||
|
String(value ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -104,5 +104,80 @@ export function createCourseReferenceDataService(
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data;
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,21 +13,31 @@ export function createCourseStatisticsService(
|
|||||||
{ p_account_id: accountId },
|
{ p_account_id: accountId },
|
||||||
);
|
);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
// RPC returns a single row as an array
|
// RPC returns a single row as an array with snake_case keys
|
||||||
const stats = Array.isArray(data) ? data[0] : data;
|
const raw = Array.isArray(data) ? data[0] : data;
|
||||||
return (
|
const s = raw ?? {
|
||||||
stats ?? {
|
total_courses: 0,
|
||||||
total_courses: 0,
|
open_courses: 0,
|
||||||
open_courses: 0,
|
running_courses: 0,
|
||||||
running_courses: 0,
|
completed_courses: 0,
|
||||||
completed_courses: 0,
|
cancelled_courses: 0,
|
||||||
cancelled_courses: 0,
|
total_participants: 0,
|
||||||
total_participants: 0,
|
total_waitlisted: 0,
|
||||||
total_waitlisted: 0,
|
avg_occupancy_rate: 0,
|
||||||
avg_occupancy_rate: 0,
|
total_revenue: 0,
|
||||||
total_revenue: 0,
|
};
|
||||||
}
|
// Normalise to camelCase for consumers
|
||||||
);
|
return {
|
||||||
|
totalCourses: s.total_courses ?? s.totalCourses ?? 0,
|
||||||
|
openCourses: s.open_courses ?? s.openCourses ?? 0,
|
||||||
|
runningCourses: s.running_courses ?? s.runningCourses ?? 0,
|
||||||
|
completedCourses: s.completed_courses ?? s.completedCourses ?? 0,
|
||||||
|
cancelledCourses: s.cancelled_courses ?? s.cancelledCourses ?? 0,
|
||||||
|
totalParticipants: s.total_participants ?? s.totalParticipants ?? 0,
|
||||||
|
totalWaitlisted: s.total_waitlisted ?? s.totalWaitlisted ?? 0,
|
||||||
|
avgOccupancyRate: s.avg_occupancy_rate ?? s.avgOccupancyRate ?? 0,
|
||||||
|
totalRevenue: s.total_revenue ?? s.totalRevenue ?? 0,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAttendanceSummary(courseId: string) {
|
async getAttendanceSummary(courseId: string) {
|
||||||
|
|||||||
@@ -35,5 +35,8 @@
|
|||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-hook-form": "catalog:",
|
"react-hook-form": "catalog:",
|
||||||
"zod": "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 { CreateEventForm } from './create-event-form';
|
||||||
|
export { EventRegistrationDialog } from './event-registration-dialog';
|
||||||
|
export { CreateHolidayPassDialog } from './create-holiday-pass-dialog';
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
interface CommunicationListOptions {
|
||||||
|
type?: string;
|
||||||
|
direction?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateCommunicationInput {
|
||||||
|
accountId: string;
|
||||||
|
entityId: string;
|
||||||
|
type: string;
|
||||||
|
direction?: string;
|
||||||
|
subject?: string;
|
||||||
|
body?: string;
|
||||||
|
emailTo?: string;
|
||||||
|
emailCc?: string;
|
||||||
|
attachmentPaths?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEventCommunicationService(
|
||||||
|
client: SupabaseClient<Database>,
|
||||||
|
) {
|
||||||
|
return new EventCommunicationService(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventCommunicationService {
|
||||||
|
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
eventId: string,
|
||||||
|
accountId: string,
|
||||||
|
opts?: CommunicationListOptions,
|
||||||
|
) {
|
||||||
|
let query = (this.client.from as CallableFunction)('module_communications')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.eq('module', 'events')
|
||||||
|
.eq('entity_id', eventId)
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (opts?.type) query = query.eq('type', opts.type);
|
||||||
|
if (opts?.direction) query = query.eq('direction', opts.direction);
|
||||||
|
if (opts?.search) {
|
||||||
|
const escaped = opts.search.replace(/[%_\\]/g, '\\$&');
|
||||||
|
query = query.or(`subject.ilike.%${escaped}%,body.ilike.%${escaped}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = opts?.page ?? 1;
|
||||||
|
const pageSize = opts?.pageSize ?? 25;
|
||||||
|
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
||||||
|
|
||||||
|
const { data, error, count } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
return { data: data ?? [], total: count ?? 0, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateCommunicationInput, userId: string) {
|
||||||
|
const { data, error } = await (this.client.from as CallableFunction)(
|
||||||
|
'module_communications',
|
||||||
|
)
|
||||||
|
.insert({
|
||||||
|
account_id: input.accountId,
|
||||||
|
module: 'events',
|
||||||
|
entity_id: input.entityId,
|
||||||
|
type: input.type,
|
||||||
|
direction: input.direction ?? 'internal',
|
||||||
|
subject: input.subject ?? null,
|
||||||
|
body: input.body ?? null,
|
||||||
|
email_to: input.emailTo ?? null,
|
||||||
|
email_cc: input.emailCc ?? null,
|
||||||
|
attachment_paths: input.attachmentPaths ?? null,
|
||||||
|
created_by: userId,
|
||||||
|
})
|
||||||
|
.select(
|
||||||
|
'id, account_id, module, entity_id, type, direction, subject, email_to, created_at, created_by',
|
||||||
|
)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(communicationId: string) {
|
||||||
|
const { error } = await (this.client.from as CallableFunction)(
|
||||||
|
'module_communications',
|
||||||
|
)
|
||||||
|
.delete()
|
||||||
|
.eq('id', communicationId)
|
||||||
|
.eq('module', 'events');
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
export function createEventExportService(client: SupabaseClient<Database>) {
|
||||||
|
return new EventExportService(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventExportService {
|
||||||
|
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||||
|
|
||||||
|
async exportRegistrationsCsv(eventId: string): Promise<string> {
|
||||||
|
const { data: registrations, error } = await this.client
|
||||||
|
.from('event_registrations')
|
||||||
|
.select('*')
|
||||||
|
.eq('event_id', eventId)
|
||||||
|
.order('last_name');
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
if (!registrations?.length) return '';
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
'Vorname',
|
||||||
|
'Nachname',
|
||||||
|
'E-Mail',
|
||||||
|
'Telefon',
|
||||||
|
'Geburtsdatum',
|
||||||
|
'Status',
|
||||||
|
'Anmeldedatum',
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = registrations.map((r) =>
|
||||||
|
[
|
||||||
|
r.first_name,
|
||||||
|
r.last_name,
|
||||||
|
r.email ?? '',
|
||||||
|
r.phone ?? '',
|
||||||
|
r.date_of_birth ?? '',
|
||||||
|
r.status,
|
||||||
|
r.created_at ?? '',
|
||||||
|
]
|
||||||
|
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||||
|
.join(';'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [headers.join(';'), ...rows].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportEventsCsv(
|
||||||
|
accountId: string,
|
||||||
|
filters?: { status?: string },
|
||||||
|
): Promise<string> {
|
||||||
|
let query = this.client
|
||||||
|
.from('events')
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.order('event_date', { ascending: false });
|
||||||
|
|
||||||
|
if (filters?.status) {
|
||||||
|
query = query.eq('status', filters.status as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: events, error } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
if (!events?.length) return '';
|
||||||
|
|
||||||
|
const headers = ['Name', 'Status', 'Datum', 'Ort', 'Kapazitat'];
|
||||||
|
|
||||||
|
const rows = events.map((e) =>
|
||||||
|
[
|
||||||
|
e.name,
|
||||||
|
e.status,
|
||||||
|
e.event_date ?? '',
|
||||||
|
e.location ?? '',
|
||||||
|
e.capacity?.toString() ?? '',
|
||||||
|
]
|
||||||
|
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||||
|
.join(';'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [headers.join(';'), ...rows].join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
const NAMESPACE = 'event-notification';
|
||||||
|
const MODULE = 'events';
|
||||||
|
|
||||||
|
interface NotificationRule {
|
||||||
|
id: string;
|
||||||
|
channel: 'in_app' | 'email' | 'both';
|
||||||
|
recipient_type: string;
|
||||||
|
recipient_config: Record<string, unknown>;
|
||||||
|
subject_template: string | null;
|
||||||
|
message_template: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEventNotificationService(
|
||||||
|
client: SupabaseClient<Database>,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
async enqueue(
|
||||||
|
accountId: string,
|
||||||
|
triggerEvent: string,
|
||||||
|
entityId: string,
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
await (client.rpc as CallableFunction)('enqueue_module_notification', {
|
||||||
|
p_account_id: accountId,
|
||||||
|
p_module: MODULE,
|
||||||
|
p_trigger_event: triggerEvent,
|
||||||
|
p_entity_id: entityId,
|
||||||
|
p_context: context,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async processPending(): Promise<{ processed: number; sent: number }> {
|
||||||
|
const logger = await getLogger();
|
||||||
|
|
||||||
|
const { data: pending, error } = await (client.from as CallableFunction)(
|
||||||
|
'pending_module_notifications',
|
||||||
|
)
|
||||||
|
.select('*')
|
||||||
|
.eq('module', MODULE)
|
||||||
|
.is('processed_at', null)
|
||||||
|
.order('created_at')
|
||||||
|
.limit(100);
|
||||||
|
|
||||||
|
if (error || !pending?.length) return { processed: 0, sent: 0 };
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
|
||||||
|
for (const n of pending as Array<{
|
||||||
|
id: number;
|
||||||
|
account_id: string;
|
||||||
|
trigger_event: string;
|
||||||
|
context: Record<string, unknown>;
|
||||||
|
}>) {
|
||||||
|
try {
|
||||||
|
sent += await this.dispatch(
|
||||||
|
n.account_id,
|
||||||
|
n.trigger_event,
|
||||||
|
n.context ?? {},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
{ name: NAMESPACE, id: n.id, error: e },
|
||||||
|
'Dispatch failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await (client.from as CallableFunction)('pending_module_notifications')
|
||||||
|
.update({ processed_at: new Date().toISOString() })
|
||||||
|
.eq('id', n.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ name: NAMESPACE, processed: pending.length, sent },
|
||||||
|
'Batch processed',
|
||||||
|
);
|
||||||
|
return { processed: pending.length, sent };
|
||||||
|
},
|
||||||
|
|
||||||
|
async dispatch(
|
||||||
|
accountId: string,
|
||||||
|
triggerEvent: string,
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
): Promise<number> {
|
||||||
|
const { data: rules } = await (client.from as CallableFunction)(
|
||||||
|
'module_notification_rules',
|
||||||
|
)
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.eq('module', MODULE)
|
||||||
|
.eq('trigger_event', triggerEvent)
|
||||||
|
.eq('is_active', true);
|
||||||
|
|
||||||
|
if (!rules?.length) return 0;
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
|
||||||
|
for (const rule of rules as NotificationRule[]) {
|
||||||
|
const message = renderTemplate(rule.message_template, context);
|
||||||
|
|
||||||
|
if (rule.channel === 'in_app' || rule.channel === 'both') {
|
||||||
|
const { createNotificationsApi } =
|
||||||
|
await import('@kit/notifications/api');
|
||||||
|
const api = createNotificationsApi(client);
|
||||||
|
await api.createNotification({
|
||||||
|
account_id: accountId,
|
||||||
|
body: message,
|
||||||
|
type: 'info',
|
||||||
|
channel: 'in_app',
|
||||||
|
});
|
||||||
|
sent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.channel === 'email' || rule.channel === 'both') {
|
||||||
|
const subject = rule.subject_template
|
||||||
|
? renderTemplate(rule.subject_template, context)
|
||||||
|
: triggerEvent;
|
||||||
|
const email = context.email as string | undefined;
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
const { getMailer } = await import('@kit/mailers');
|
||||||
|
const mailer = await getMailer();
|
||||||
|
await mailer.sendEmail({
|
||||||
|
to: email,
|
||||||
|
from: process.env.EMAIL_SENDER ?? 'noreply@example.com',
|
||||||
|
subject,
|
||||||
|
html: `<div style="font-family:sans-serif;max-width:600px;margin:0 auto">${message}</div>`,
|
||||||
|
});
|
||||||
|
sent++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sent;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplate(
|
||||||
|
template: string,
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
): string {
|
||||||
|
let result = template;
|
||||||
|
for (const [key, value] of Object.entries(context)) {
|
||||||
|
result = result.replace(
|
||||||
|
new RegExp(`\\{\\{${key}\\}\\}`, 'g'),
|
||||||
|
String(value ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -33,5 +33,8 @@
|
|||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-hook-form": "catalog:",
|
"react-hook-form": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export { CreateInvoiceForm } from './create-invoice-form';
|
export { CreateInvoiceForm } from './create-invoice-form';
|
||||||
export { CreateSepaBatchForm } from './create-sepa-batch-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
|
<DetailRow
|
||||||
label="Geschlecht"
|
label="Geschlecht"
|
||||||
value={String(member.gender ?? '—')}
|
value={
|
||||||
|
member.gender
|
||||||
|
? { male: 'Männlich', female: 'Weiblich', diverse: 'Divers' }[member.gender as string] ?? String(member.gender)
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -211,7 +215,18 @@ export function MemberDetailTabs({
|
|||||||
/>
|
/>
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label="Land"
|
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -56,102 +56,112 @@ const dateNotFutureSchema = (fieldName: string) =>
|
|||||||
|
|
||||||
// --- Main schemas ---
|
// --- Main schemas ---
|
||||||
|
|
||||||
export const CreateMemberSchema = z
|
// Base object without refinements — used for .partial() in UpdateMemberSchema
|
||||||
.object({
|
const CreateMemberBaseSchema = z.object({
|
||||||
accountId: z.string().uuid(),
|
accountId: z.string().uuid(),
|
||||||
memberNumber: z.string().optional(),
|
memberNumber: z.string().optional(),
|
||||||
firstName: z.string().min(1).max(128),
|
firstName: z.string().min(1).max(128),
|
||||||
lastName: z.string().min(1).max(128),
|
lastName: z.string().min(1).max(128),
|
||||||
dateOfBirth: dateNotFutureSchema('Geburtsdatum'),
|
dateOfBirth: dateNotFutureSchema('Geburtsdatum'),
|
||||||
gender: z.enum(['male', 'female', 'diverse']).optional(),
|
gender: z.enum(['male', 'female', 'diverse']).optional(),
|
||||||
title: z.string().max(32).optional(),
|
title: z.string().max(32).optional(),
|
||||||
email: z.string().email().optional().or(z.literal('')),
|
email: z.string().email().optional().or(z.literal('')),
|
||||||
phone: z.string().max(32).optional(),
|
phone: z.string().max(32).optional(),
|
||||||
mobile: z.string().max(32).optional(),
|
mobile: z.string().max(32).optional(),
|
||||||
street: z.string().max(256).optional(),
|
street: z.string().max(256).optional(),
|
||||||
houseNumber: z.string().max(16).optional(),
|
houseNumber: z.string().max(16).optional(),
|
||||||
postalCode: z.string().max(10).optional(),
|
postalCode: z.string().max(10).optional(),
|
||||||
city: z.string().max(128).optional(),
|
city: z.string().max(128).optional(),
|
||||||
country: z.string().max(2).default('DE'),
|
country: z.string().max(2).default('DE'),
|
||||||
status: MembershipStatusEnum.default('active'),
|
status: MembershipStatusEnum.default('active'),
|
||||||
entryDate: z
|
entryDate: z
|
||||||
.string()
|
.string()
|
||||||
.default(() => new Date().toISOString().split('T')[0]!),
|
.default(() => new Date().toISOString().split('T')[0]!),
|
||||||
duesCategoryId: z.string().uuid().optional(),
|
duesCategoryId: z.string().uuid().optional(),
|
||||||
iban: ibanSchema,
|
iban: ibanSchema,
|
||||||
bic: z.string().max(11).optional(),
|
bic: z.string().max(11).optional(),
|
||||||
accountHolder: z.string().max(128).optional(),
|
accountHolder: z.string().max(128).optional(),
|
||||||
gdprConsent: z.boolean().default(false),
|
gdprConsent: z.boolean().default(false),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
salutation: z.string().optional(),
|
salutation: z.string().optional(),
|
||||||
street2: z.string().optional(),
|
street2: z.string().optional(),
|
||||||
phone2: z.string().optional(),
|
phone2: z.string().optional(),
|
||||||
fax: z.string().optional(),
|
fax: z.string().optional(),
|
||||||
birthplace: z.string().optional(),
|
birthplace: z.string().optional(),
|
||||||
birthCountry: z.string().default('DE'),
|
birthCountry: z.string().default('DE'),
|
||||||
isHonorary: z.boolean().default(false),
|
isHonorary: z.boolean().default(false),
|
||||||
isFoundingMember: z.boolean().default(false),
|
isFoundingMember: z.boolean().default(false),
|
||||||
isYouth: z.boolean().default(false),
|
isYouth: z.boolean().default(false),
|
||||||
isRetiree: z.boolean().default(false),
|
isRetiree: z.boolean().default(false),
|
||||||
isProbationary: z.boolean().default(false),
|
isProbationary: z.boolean().default(false),
|
||||||
isTransferred: z.boolean().default(false),
|
isTransferred: z.boolean().default(false),
|
||||||
exitDate: z.string().optional(),
|
exitDate: z.string().optional(),
|
||||||
exitReason: z.string().optional(),
|
exitReason: z.string().optional(),
|
||||||
guardianName: z.string().optional(),
|
guardianName: z.string().optional(),
|
||||||
guardianPhone: z.string().optional(),
|
guardianPhone: z.string().optional(),
|
||||||
guardianEmail: z.string().optional(),
|
guardianEmail: z.string().optional(),
|
||||||
duesYear: z.number().int().optional(),
|
duesYear: z.number().int().optional(),
|
||||||
duesPaid: z.boolean().default(false),
|
duesPaid: z.boolean().default(false),
|
||||||
additionalFees: z.number().default(0),
|
additionalFees: z.number().default(0),
|
||||||
exemptionType: z.string().optional(),
|
exemptionType: z.string().optional(),
|
||||||
exemptionReason: z.string().optional(),
|
exemptionReason: z.string().optional(),
|
||||||
exemptionAmount: z.number().optional(),
|
exemptionAmount: z.number().optional(),
|
||||||
gdprNewsletter: z.boolean().default(false),
|
gdprNewsletter: z.boolean().default(false),
|
||||||
gdprInternet: z.boolean().default(false),
|
gdprInternet: z.boolean().default(false),
|
||||||
gdprPrint: z.boolean().default(false),
|
gdprPrint: z.boolean().default(false),
|
||||||
gdprBirthdayInfo: z.boolean().default(false),
|
gdprBirthdayInfo: z.boolean().default(false),
|
||||||
sepaMandateReference: z.string().optional(),
|
sepaMandateReference: z.string().optional(),
|
||||||
})
|
});
|
||||||
.superRefine((data, ctx) => {
|
|
||||||
// Cross-field: exit_date must be after entry_date
|
|
||||||
if (data.exitDate && data.entryDate && data.exitDate < data.entryDate) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'Austrittsdatum muss nach dem Eintrittsdatum liegen',
|
|
||||||
path: ['exitDate'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cross-field: entry_date must be after date_of_birth
|
/** Cross-field refinement shared by create/update */
|
||||||
if (
|
function memberCrossFieldRefinement(
|
||||||
data.dateOfBirth &&
|
data: Record<string, unknown>,
|
||||||
data.entryDate &&
|
ctx: z.RefinementCtx,
|
||||||
data.entryDate < data.dateOfBirth
|
) {
|
||||||
) {
|
// Cross-field: exit_date must be after entry_date
|
||||||
ctx.addIssue({
|
if (data.exitDate && data.entryDate && data.exitDate < data.entryDate) {
|
||||||
code: z.ZodIssueCode.custom,
|
ctx.addIssue({
|
||||||
message: 'Eintrittsdatum muss nach dem Geburtsdatum liegen',
|
code: z.ZodIssueCode.custom,
|
||||||
path: ['entryDate'],
|
message: 'Austrittsdatum muss nach dem Eintrittsdatum liegen',
|
||||||
});
|
path: ['exitDate'],
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Cross-field: youth members should have guardian info
|
// Cross-field: entry_date must be after date_of_birth
|
||||||
if (data.isYouth && !data.guardianName) {
|
if (
|
||||||
ctx.addIssue({
|
data.dateOfBirth &&
|
||||||
code: z.ZodIssueCode.custom,
|
data.entryDate &&
|
||||||
message: 'Jugendmitglieder benötigen einen Erziehungsberechtigten',
|
data.entryDate < data.dateOfBirth
|
||||||
path: ['guardianName'],
|
) {
|
||||||
});
|
ctx.addIssue({
|
||||||
}
|
code: z.ZodIssueCode.custom,
|
||||||
});
|
message: 'Eintrittsdatum muss nach dem Geburtsdatum liegen',
|
||||||
|
path: ['entryDate'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-field: youth members should have guardian info
|
||||||
|
if (data.isYouth && !data.guardianName) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Jugendmitglieder benötigen einen Erziehungsberechtigten',
|
||||||
|
path: ['guardianName'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateMemberSchema =
|
||||||
|
CreateMemberBaseSchema.superRefine(memberCrossFieldRefinement);
|
||||||
|
|
||||||
export type CreateMemberInput = z.infer<typeof CreateMemberSchema>;
|
export type CreateMemberInput = z.infer<typeof CreateMemberSchema>;
|
||||||
|
|
||||||
export const UpdateMemberSchema = CreateMemberSchema.partial().extend({
|
export const UpdateMemberSchema = CreateMemberBaseSchema.partial()
|
||||||
memberId: z.string().uuid(),
|
.extend({
|
||||||
isArchived: z.boolean().optional(),
|
memberId: z.string().uuid(),
|
||||||
version: z.number().int().optional(),
|
isArchived: z.boolean().optional(),
|
||||||
});
|
version: z.number().int().optional(),
|
||||||
|
})
|
||||||
|
.superRefine(memberCrossFieldRefinement);
|
||||||
|
|
||||||
export type UpdateMemberInput = z.infer<typeof UpdateMemberSchema>;
|
export type UpdateMemberInput = z.infer<typeof UpdateMemberSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -33,5 +33,8 @@
|
|||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-hook-form": "catalog:",
|
"react-hook-form": "catalog:",
|
||||||
"zod": "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 { CreateNewsletterForm } from './create-newsletter-form';
|
||||||
|
export { CreateTemplateDialog } from './create-template-dialog';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
135
qa-checklist.md
Normal file
135
qa-checklist.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# QA Checklist — MYeasyCMS v2
|
||||||
|
|
||||||
|
**Date**: 2026-04-03
|
||||||
|
**Test method**: Docker production build (`docker compose -f docker-compose.local.yml up --build`)
|
||||||
|
**Test user**: `test@makerkit.dev` / `testingpassword` (super-admin)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUGS FOUND & FIXED
|
||||||
|
|
||||||
|
### BUG #1 — Members CMS crashes with Zod v4 error (CRITICAL) ✅ FIXED
|
||||||
|
- **Route**: `/home/[account]/members-cms`
|
||||||
|
- **Error**: `Error: .partial() cannot be used on object schemas containing refinements`
|
||||||
|
- **Root cause**: `CreateMemberSchema` uses `.superRefine()` for cross-field validation, then `UpdateMemberSchema = CreateMemberSchema.partial()` fails in Zod v4.
|
||||||
|
- **Fix**: Separated base object schema from refinements. `CreateMemberBaseSchema` (plain object) is used for `.partial()`, and the refinement function `memberCrossFieldRefinement` is applied separately to both Create and Update schemas.
|
||||||
|
- **File**: `packages/features/member-management/src/schema/member.schema.ts`
|
||||||
|
- **Status**: ✅ Verified in Docker rebuild — members page loads with 30 members displayed
|
||||||
|
|
||||||
|
### BUG #2 — Course stats cards show empty values (MEDIUM) ✅ FIXED
|
||||||
|
- **Route**: `/home/[account]/courses`
|
||||||
|
- **Symptom**: Stats cards showed labels (Gesamt, Aktiv, Abgeschlossen, Teilnehmer) but no numbers
|
||||||
|
- **Root cause**: The `getQuickStats` RPC returns snake_case keys (`total_courses`, `open_courses`) but the template uses camelCase (`stats.totalCourses`, `stats.openCourses`), resulting in `undefined`
|
||||||
|
- **Fix**: Added camelCase normalization in `createCourseStatisticsService.getQuickStats()`
|
||||||
|
- **File**: `packages/features/course-management/src/server/services/course-statistics.service.ts`
|
||||||
|
- **Status**: ✅ Verified in Docker rebuild — all 4 stats cards show "0" correctly
|
||||||
|
|
||||||
|
### BUG #3 — Blog post images missing (MEDIUM) ✅ FIXED
|
||||||
|
- **Route**: `/blog`
|
||||||
|
- **Symptom**: All blog post cards showed broken image alt text instead of cover images
|
||||||
|
- **Root cause**: Blog posts reference images like `/images/posts/mitgliederverwaltung.webp` but only 3 default Makerkit images existed in `public/images/posts/`
|
||||||
|
- **Fix**: Created placeholder images for all 5 German blog posts
|
||||||
|
- **Files added**: `apps/web/public/images/posts/{mitgliederverwaltung,dsgvo-vereine,sepa-lastschrift,digitale-verwaltung,vereinswebsite}.webp`
|
||||||
|
- **Status**: ✅ Verified in Docker rebuild — all blog images load
|
||||||
|
|
||||||
|
### BUG #4 — Dev bootstrap references non-existent table (LOW) ✅ FIXED
|
||||||
|
- **Error**: `ERROR: relation "public.catch_entries" does not exist` during DB seeding
|
||||||
|
- **Root cause**: `docker/db/dev-bootstrap.sh` grants permissions on `catch_entries` table, but no migration creates it. The actual table is `catch_books`.
|
||||||
|
- **Fix**: Removed the `catch_entries` GRANT line from dev-bootstrap.sh
|
||||||
|
- **File**: `docker/db/dev-bootstrap.sh`
|
||||||
|
- **Status**: ✅ Fixed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KNOWN ISSUES (NOT FIXED — LOW PRIORITY)
|
||||||
|
|
||||||
|
### ISSUE #5 — Admin panel not translated to German
|
||||||
|
- **Route**: `/admin`
|
||||||
|
- **Symptom**: Admin dashboard shows English labels ("Users", "Team Accounts", "Paying Customers") while rest of app is in German
|
||||||
|
- **Impact**: Low — admin panel is internal-facing only
|
||||||
|
- **Fix needed**: Add German translations for admin section in i18n
|
||||||
|
|
||||||
|
### ISSUE #6 — Hydration mismatch warning (dev-only)
|
||||||
|
- **Source**: `next-runtime-env` `PublicEnvScript` component
|
||||||
|
- **Impact**: None — this is a known Next.js 16 framework-level issue with script serialization. The `suppressHydrationWarning` is already set on `<html>`.
|
||||||
|
- **Fix needed**: None — wait for upstream fix in `next-runtime-env`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PAGES TESTED — ALL PASSING ✅
|
||||||
|
|
||||||
|
### Marketing Pages (Public)
|
||||||
|
| Route | Status | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `/` (Homepage) | ✅ Pass | Hero, stats, features, testimonials, pricing, CTA all render |
|
||||||
|
| `/blog` | ✅ Pass | Blog cards with images, dates, descriptions |
|
||||||
|
| `/docs` | ✅ Pass | Documentation sidebar navigation, category cards |
|
||||||
|
| `/pricing` | ✅ Pass | 4 pricing tiers, monthly/yearly toggle, feature lists |
|
||||||
|
| `/faq` | ✅ Pass | Accordion FAQ items with expand/collapse |
|
||||||
|
| `/contact` | ✅ Pass | Contact form with name, email, message fields |
|
||||||
|
|
||||||
|
### Auth Pages
|
||||||
|
| Route | Status | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `/auth/sign-in` | ✅ Pass | Email/password login, social auth buttons (Google, Apple, Azure, GitHub) |
|
||||||
|
| `/auth/sign-up` | ✅ Pass (navigable) | Registration form |
|
||||||
|
|
||||||
|
### Personal Dashboard
|
||||||
|
| Route | Status | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `/home` | ✅ Pass | Personal home with sidebar, account selector |
|
||||||
|
| `/home/settings` | ✅ Pass (navigable) | Profile settings |
|
||||||
|
|
||||||
|
### Team Dashboard (Makerkit workspace)
|
||||||
|
| Route | Status | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `/home/makerkit` | ✅ Pass | Dashboard with member stats, course stats, invoices, newsletters, quick actions |
|
||||||
|
| `/home/makerkit/members-cms` | ✅ Pass | Member list with search, filters, status badges, pagination (30 members) |
|
||||||
|
| `/home/makerkit/courses` | ✅ Pass | Course list with stats cards, search, status filter |
|
||||||
|
| `/home/makerkit/events` | ✅ Pass | Events list with stats (Veranstaltungen, Orte, Kapazität) |
|
||||||
|
| `/home/makerkit/finance` | ✅ Pass | SEPA + Invoices overview with stats |
|
||||||
|
| `/home/makerkit/newsletter` | ✅ Pass | Newsletter list with stats |
|
||||||
|
| `/home/makerkit/bookings` | ✅ Pass | Booking management with rooms/active/revenue stats |
|
||||||
|
| `/home/makerkit/documents` | ✅ Pass | Document generator cards (Mitgliedsausweis, Rechnung, Etiketten, etc.) |
|
||||||
|
| `/home/makerkit/site-builder` | ✅ Pass | Page builder with settings, posts, status |
|
||||||
|
| `/home/makerkit/meetings` | ✅ Pass | Meeting protocols with stats (Protokolle, Aufgaben) |
|
||||||
|
| `/home/makerkit/fischerei` | ✅ Pass | Fishing module with 8 tabs, 6 stat cards |
|
||||||
|
| `/home/makerkit/verband` | ✅ Pass | Federation management with 9 tabs, 6 stat cards |
|
||||||
|
| `/home/makerkit/settings` | ✅ Pass | Team logo, team name, danger zone |
|
||||||
|
|
||||||
|
### Admin Panel
|
||||||
|
| Route | Status | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `/admin` | ✅ Pass | Super admin dashboard with Users/Team/Paying/Trials stats |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## INTERACTIVE ELEMENTS VERIFIED
|
||||||
|
|
||||||
|
- ✅ Navigation links (Blog, Docs, Pricing, FAQ, Contact)
|
||||||
|
- ✅ Auth flow (sign-in with email/password)
|
||||||
|
- ✅ Account/workspace selector dropdown
|
||||||
|
- ✅ Sidebar navigation (collapsed/expanded)
|
||||||
|
- ✅ Stats cards (all modules)
|
||||||
|
- ✅ Member list table with avatars, status badges, tags
|
||||||
|
- ✅ Search inputs (members, courses, bookings, newsletter)
|
||||||
|
- ✅ Status filter dropdowns
|
||||||
|
- ✅ Tab navigation (Fischerei, Meetings, Verband)
|
||||||
|
- ✅ Quick action buttons (New Member, New Course, etc.)
|
||||||
|
- ✅ FAQ accordion expand/collapse
|
||||||
|
- ✅ Theme toggle button
|
||||||
|
- ✅ Sign In / Sign Up buttons
|
||||||
|
- ✅ Breadcrumb navigation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FILES MODIFIED
|
||||||
|
|
||||||
|
1. `packages/features/member-management/src/schema/member.schema.ts` — Zod v4 partial() fix
|
||||||
|
2. `packages/features/course-management/src/server/services/course-statistics.service.ts` — snake_case→camelCase normalization
|
||||||
|
3. `apps/web/public/images/posts/mitgliederverwaltung.webp` — Added blog image
|
||||||
|
4. `apps/web/public/images/posts/dsgvo-vereine.webp` — Added blog image
|
||||||
|
5. `apps/web/public/images/posts/sepa-lastschrift.webp` — Added blog image
|
||||||
|
6. `apps/web/public/images/posts/digitale-verwaltung.webp` — Added blog image
|
||||||
|
7. `apps/web/public/images/posts/vereinswebsite.webp` — Added blog image
|
||||||
|
8. `docker/db/dev-bootstrap.sh` — Removed non-existent catch_entries table reference
|
||||||
Reference in New Issue
Block a user