Initial state for GitNexus analysis
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
BedDouble,
|
||||
CalendarDays,
|
||||
LogIn,
|
||||
LogOut,
|
||||
XCircle,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
|
||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string; bookingId: string }>;
|
||||
}
|
||||
|
||||
const STATUS_BADGE_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
pending: 'secondary',
|
||||
confirmed: 'default',
|
||||
checked_in: 'info',
|
||||
checked_out: 'outline',
|
||||
cancelled: 'destructive',
|
||||
no_show: 'destructive',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: 'Ausstehend',
|
||||
confirmed: 'Bestätigt',
|
||||
checked_in: 'Eingecheckt',
|
||||
checked_out: 'Ausgecheckt',
|
||||
cancelled: 'Storniert',
|
||||
no_show: 'Nicht erschienen',
|
||||
};
|
||||
|
||||
export default async function BookingDetailPage({ params }: PageProps) {
|
||||
const { account, bookingId } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createBookingManagementApi(client);
|
||||
|
||||
// Load booking directly
|
||||
const { data: booking } = await client
|
||||
.from('bookings')
|
||||
.select('*')
|
||||
.eq('id', bookingId)
|
||||
.eq('account_id', acct.id)
|
||||
.single();
|
||||
|
||||
if (!booking) {
|
||||
return (
|
||||
<CmsPageShell account={account} title="Buchung nicht gefunden">
|
||||
<div className="flex flex-col items-center gap-4 py-12">
|
||||
<p className="text-muted-foreground">
|
||||
Buchung mit ID "{bookingId}" wurde nicht gefunden.
|
||||
</p>
|
||||
<Link href={`/home/${account}/bookings`}>
|
||||
<Button variant="outline">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Zurück zu Buchungen
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
// Load related room and guest data
|
||||
const [roomResult, guestResult] = await Promise.all([
|
||||
booking.room_id
|
||||
? client.from('rooms').select('*').eq('id', booking.room_id).single()
|
||||
: Promise.resolve({ data: null }),
|
||||
booking.guest_id
|
||||
? client.from('guests').select('*').eq('id', booking.guest_id).single()
|
||||
: Promise.resolve({ data: null }),
|
||||
]);
|
||||
|
||||
const room = roomResult.data;
|
||||
const guest = guestResult.data;
|
||||
const status = String(booking.status ?? 'pending');
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Buchungsdetails">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/home/${account}/bookings`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">Buchungsdetails</h1>
|
||||
<Badge variant={STATUS_BADGE_VARIANT[status] ?? 'secondary'}>
|
||||
{STATUS_LABEL[status] ?? status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
ID: {bookingId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Zimmer */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BedDouble className="h-5 w-5" />
|
||||
Zimmer
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{room ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Zimmernummer
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{String(room.room_number)}
|
||||
</span>
|
||||
</div>
|
||||
{room.name && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Name
|
||||
</span>
|
||||
<span className="font-medium">{String(room.name)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">Typ</span>
|
||||
<span className="font-medium">
|
||||
{String(room.room_type ?? '—')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Kein Zimmer zugewiesen
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Gast */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Gast
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{guest ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">Name</span>
|
||||
<span className="font-medium">
|
||||
{String(guest.first_name)} {String(guest.last_name)}
|
||||
</span>
|
||||
</div>
|
||||
{guest.email && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
E-Mail
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{String(guest.email)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{guest.phone && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Telefon
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{String(guest.phone)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Kein Gast zugewiesen
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Aufenthalt */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CalendarDays className="h-5 w-5" />
|
||||
Aufenthalt
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Check-in
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{booking.check_in
|
||||
? new Date(String(booking.check_in)).toLocaleDateString(
|
||||
'de-DE',
|
||||
{
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
},
|
||||
)
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Check-out
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{booking.check_out
|
||||
? new Date(String(booking.check_out)).toLocaleDateString(
|
||||
'de-DE',
|
||||
{
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
},
|
||||
)
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Erwachsene
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{booking.adults ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Kinder
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{booking.children ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Betrag */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Betrag</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Gesamtpreis
|
||||
</span>
|
||||
<span className="text-2xl font-bold">
|
||||
{booking.total_price != null
|
||||
? `${Number(booking.total_price).toFixed(2)} €`
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
{booking.notes && (
|
||||
<div className="border-t pt-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Notizen
|
||||
</span>
|
||||
<p className="mt-1 text-sm">{String(booking.notes)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Status Workflow */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Aktionen</CardTitle>
|
||||
<CardDescription>
|
||||
Status der Buchung ändern
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{(status === 'pending' || status === 'confirmed') && (
|
||||
<Button variant="default">
|
||||
<LogIn className="mr-2 h-4 w-4" />
|
||||
Einchecken
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status === 'checked_in' && (
|
||||
<Button variant="default">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Auschecken
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status !== 'cancelled' &&
|
||||
status !== 'checked_out' &&
|
||||
status !== 'no_show' && (
|
||||
<Button variant="destructive">
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Stornieren
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status === 'cancelled' || status === 'checked_out' ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
Diese Buchung ist{' '}
|
||||
{status === 'cancelled' ? 'storniert' : 'abgeschlossen'} — keine
|
||||
weiteren Aktionen verfügbar.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
230
apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx
Normal file
230
apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'Januar',
|
||||
'Februar',
|
||||
'März',
|
||||
'April',
|
||||
'Mai',
|
||||
'Juni',
|
||||
'Juli',
|
||||
'August',
|
||||
'September',
|
||||
'Oktober',
|
||||
'November',
|
||||
'Dezember',
|
||||
];
|
||||
|
||||
function getDaysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
function getFirstWeekday(year: number, month: number): number {
|
||||
const day = new Date(year, month, 1).getDay();
|
||||
// Convert Sunday=0 to Monday-based (Mo=0, Di=1 … So=6)
|
||||
return day === 0 ? 6 : day - 1;
|
||||
}
|
||||
|
||||
function isDateInRange(date: string, checkIn: string, checkOut: string): boolean {
|
||||
return date >= checkIn && date < checkOut;
|
||||
}
|
||||
|
||||
export default async function BookingCalendarPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createBookingManagementApi(client);
|
||||
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
const daysInMonth = getDaysInMonth(year, month);
|
||||
const firstWeekday = getFirstWeekday(year, month);
|
||||
|
||||
// Load bookings for this month
|
||||
const monthStart = `${year}-${String(month + 1).padStart(2, '0')}-01`;
|
||||
const monthEnd = `${year}-${String(month + 1).padStart(2, '0')}-${String(daysInMonth).padStart(2, '0')}`;
|
||||
|
||||
const bookings = await api.listBookings(acct.id, {
|
||||
from: monthStart,
|
||||
to: monthEnd,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
// Build set of occupied dates
|
||||
const occupiedDates = new Set<string>();
|
||||
for (const booking of bookings.data) {
|
||||
const b = booking as Record<string, unknown>;
|
||||
if (b.status === 'cancelled' || b.status === 'no_show') continue;
|
||||
const checkIn = String(b.check_in ?? '');
|
||||
const checkOut = String(b.check_out ?? '');
|
||||
if (!checkIn || !checkOut) continue;
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
if (isDateInRange(dateStr, checkIn, checkOut)) {
|
||||
occupiedDates.add(dateStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build calendar grid cells
|
||||
const cells: Array<{ day: number | null; occupied: boolean; isToday: boolean }> = [];
|
||||
|
||||
// Empty cells before first day
|
||||
for (let i = 0; i < firstWeekday; i++) {
|
||||
cells.push({ day: null, occupied: false, isToday: false });
|
||||
}
|
||||
|
||||
const todayStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
cells.push({
|
||||
day: d,
|
||||
occupied: occupiedDates.has(dateStr),
|
||||
isToday: dateStr === todayStr,
|
||||
});
|
||||
}
|
||||
|
||||
// Fill remaining cells to complete the grid
|
||||
while (cells.length % 7 !== 0) {
|
||||
cells.push({ day: null, occupied: false, isToday: false });
|
||||
}
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Belegungskalender">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/home/${account}/bookings`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Belegungskalender</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Zimmerauslastung im Überblick
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Month Navigation */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" size="icon" disabled>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<CardTitle>
|
||||
{MONTH_NAMES[month]} {year}
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="icon" disabled>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Weekday Header */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
{WEEKDAYS.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="text-center text-xs font-medium text-muted-foreground py-2"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{cells.map((cell, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`relative flex h-12 items-center justify-center rounded-md text-sm transition-colors ${
|
||||
cell.day === null
|
||||
? 'bg-transparent'
|
||||
: cell.occupied
|
||||
? 'bg-primary/15 text-primary font-semibold'
|
||||
: 'bg-muted/30 hover:bg-muted/50'
|
||||
} ${cell.isToday ? 'ring-2 ring-primary ring-offset-1' : ''}`}
|
||||
>
|
||||
{cell.day !== null && (
|
||||
<>
|
||||
<span>{cell.day}</span>
|
||||
{cell.occupied && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-3 rounded-sm bg-primary/15" />
|
||||
Belegt
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-3 rounded-sm bg-muted/30" />
|
||||
Frei
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-3 rounded-sm ring-2 ring-primary" />
|
||||
Heute
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Buchungen in diesem Monat
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{bookings.data.length}</p>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{occupiedDates.size} von {daysInMonth} Tagen belegt
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { UserCircle, Plus } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function GuestsPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createBookingManagementApi(client);
|
||||
const guests = await api.listGuests(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Gäste">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Gäste</h1>
|
||||
<p className="text-muted-foreground">Gästeverwaltung</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Gast
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{guests.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<UserCircle className="h-8 w-8" />}
|
||||
title="Keine Gäste vorhanden"
|
||||
description="Legen Sie Ihren ersten Gast an."
|
||||
actionLabel="Neuer Gast"
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Gäste ({guests.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
||||
<th className="p-3 text-left font-medium">Telefon</th>
|
||||
<th className="p-3 text-left font-medium">Stadt</th>
|
||||
<th className="p-3 text-left font-medium">Land</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{guests.map((guest: Record<string, unknown>) => (
|
||||
<tr key={String(guest.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">
|
||||
{String(guest.last_name ?? '')}, {String(guest.first_name ?? '')}
|
||||
</td>
|
||||
<td className="p-3">{String(guest.email ?? '—')}</td>
|
||||
<td className="p-3">{String(guest.phone ?? '—')}</td>
|
||||
<td className="p-3">{String(guest.city ?? '—')}</td>
|
||||
<td className="p-3">{String(guest.country ?? '—')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
235
apps/web/app/[locale]/home/[account]/bookings/new/page.tsx
Normal file
235
apps/web/app/[locale]/home/[account]/bookings/new/page.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft, BedDouble } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
|
||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewBookingPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createBookingManagementApi(client);
|
||||
|
||||
const [rooms, guests] = await Promise.all([
|
||||
api.listRooms(acct.id),
|
||||
api.listGuests(acct.id),
|
||||
]);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neue Buchung">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/home/${account}/bookings`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Neue Buchung</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Buchung für ein Zimmer erstellen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-col gap-6">
|
||||
{/* Zimmer & Gast */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BedDouble className="h-5 w-5" />
|
||||
Zimmer & Gast
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Wählen Sie Zimmer und Gast für die Buchung aus
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="room_id"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Zimmer
|
||||
</label>
|
||||
<select
|
||||
id="room_id"
|
||||
name="room_id"
|
||||
required
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">Zimmer wählen…</option>
|
||||
{rooms.map((room: Record<string, unknown>) => (
|
||||
<option key={String(room.id)} value={String(room.id)}>
|
||||
{String(room.room_number)} – {String(room.name ?? room.room_type ?? '')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="guest_id"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Gast
|
||||
</label>
|
||||
<select
|
||||
id="guest_id"
|
||||
name="guest_id"
|
||||
required
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">Gast wählen…</option>
|
||||
{guests.map((guest: Record<string, unknown>) => (
|
||||
<option key={String(guest.id)} value={String(guest.id)}>
|
||||
{String(guest.last_name)}, {String(guest.first_name)}
|
||||
{guest.email ? ` (${String(guest.email)})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Aufenthalt */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Aufenthalt</CardTitle>
|
||||
<CardDescription>
|
||||
Reisedaten und Personenanzahl
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="check_in" className="text-sm font-medium">
|
||||
Anreise
|
||||
</label>
|
||||
<input
|
||||
id="check_in"
|
||||
name="check_in"
|
||||
type="date"
|
||||
required
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="check_out" className="text-sm font-medium">
|
||||
Abreise
|
||||
</label>
|
||||
<input
|
||||
id="check_out"
|
||||
name="check_out"
|
||||
type="date"
|
||||
required
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="adults" className="text-sm font-medium">
|
||||
Erwachsene
|
||||
</label>
|
||||
<input
|
||||
id="adults"
|
||||
name="adults"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={1}
|
||||
required
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="children" className="text-sm font-medium">
|
||||
Kinder
|
||||
</label>
|
||||
<input
|
||||
id="children"
|
||||
name="children"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={0}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Preis & Notizen */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preis & Notizen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5 md:w-1/2">
|
||||
<label htmlFor="total_price" className="text-sm font-medium">
|
||||
Preis (€)
|
||||
</label>
|
||||
<input
|
||||
id="total_price"
|
||||
name="total_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
placeholder="0.00"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="notes" className="text-sm font-medium">
|
||||
Notizen
|
||||
</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
rows={3}
|
||||
placeholder="Zusätzliche Informationen zur Buchung…"
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Link href={`/home/${account}/bookings`}>
|
||||
<Button type="button" variant="outline">
|
||||
Abbrechen
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit">Buchung erstellen</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
182
apps/web/app/[locale]/home/[account]/bookings/page.tsx
Normal file
182
apps/web/app/[locale]/home/[account]/bookings/page.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { BedDouble, CalendarCheck, Plus, Euro } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
const STATUS_BADGE_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
pending: 'secondary',
|
||||
confirmed: 'default',
|
||||
checked_in: 'info',
|
||||
checked_out: 'outline',
|
||||
cancelled: 'destructive',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: 'Ausstehend',
|
||||
confirmed: 'Bestätigt',
|
||||
checked_in: 'Eingecheckt',
|
||||
checked_out: 'Ausgecheckt',
|
||||
cancelled: 'Storniert',
|
||||
no_show: 'Nicht erschienen',
|
||||
};
|
||||
|
||||
export default async function BookingsPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createBookingManagementApi(client);
|
||||
|
||||
const [rooms, bookings] = await Promise.all([
|
||||
api.listRooms(acct.id),
|
||||
api.listBookings(acct.id, { page: 1 }),
|
||||
]);
|
||||
|
||||
const activeBookings = bookings.data.filter(
|
||||
(b: Record<string, unknown>) =>
|
||||
b.status === 'confirmed' || b.status === 'checked_in',
|
||||
);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Buchungen">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Buchungen</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Zimmer und Buchungen verwalten
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link href={`/home/${account}/bookings/new`}>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neue Buchung
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatsCard
|
||||
title="Zimmer"
|
||||
value={rooms.length}
|
||||
icon={<BedDouble className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Aktive Buchungen"
|
||||
value={activeBookings.length}
|
||||
icon={<CalendarCheck className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Gesamt"
|
||||
value={bookings.total}
|
||||
icon={<Euro className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table or Empty State */}
|
||||
{bookings.data.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<BedDouble className="h-8 w-8" />}
|
||||
title="Keine Buchungen vorhanden"
|
||||
description="Erstellen Sie Ihre erste Buchung, um loszulegen."
|
||||
actionLabel="Neue Buchung"
|
||||
actionHref={`/home/${account}/bookings/new`}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Buchungen ({bookings.total})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Zimmer</th>
|
||||
<th className="p-3 text-left font-medium">Gast</th>
|
||||
<th className="p-3 text-left font-medium">Anreise</th>
|
||||
<th className="p-3 text-left font-medium">Abreise</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-right font-medium">Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bookings.data.map((booking: Record<string, unknown>) => (
|
||||
<tr
|
||||
key={String(booking.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
>
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/home/${account}/bookings/${String(booking.id)}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{String(booking.room_id ?? '—')}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{String(booking.guest_id ?? '—')}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{booking.check_in
|
||||
? new Date(String(booking.check_in)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{booking.check_out
|
||||
? new Date(String(booking.check_out)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
STATUS_BADGE_VARIANT[String(booking.status)] ?? 'secondary'
|
||||
}
|
||||
>
|
||||
{STATUS_LABEL[String(booking.status)] ?? String(booking.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{booking.total_price != null
|
||||
? `${Number(booking.total_price).toFixed(2)} €`
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
101
apps/web/app/[locale]/home/[account]/bookings/rooms/page.tsx
Normal file
101
apps/web/app/[locale]/home/[account]/bookings/rooms/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { BedDouble, Plus } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function RoomsPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createBookingManagementApi(client);
|
||||
const rooms = await api.listRooms(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Zimmer">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Zimmer</h1>
|
||||
<p className="text-muted-foreground">Zimmerverwaltung</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neues Zimmer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{rooms.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<BedDouble className="h-8 w-8" />}
|
||||
title="Keine Zimmer vorhanden"
|
||||
description="Fügen Sie Ihr erstes Zimmer hinzu."
|
||||
actionLabel="Neues Zimmer"
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Zimmer ({rooms.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Zimmernr.</th>
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">Typ</th>
|
||||
<th className="p-3 text-right font-medium">Kapazität</th>
|
||||
<th className="p-3 text-right font-medium">Preis/Nacht</th>
|
||||
<th className="p-3 text-center font-medium">Aktiv</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rooms.map((room: Record<string, unknown>) => (
|
||||
<tr key={String(room.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-mono text-xs">
|
||||
{String(room.room_number ?? '—')}
|
||||
</td>
|
||||
<td className="p-3 font-medium">{String(room.name ?? '—')}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="outline">{String(room.room_type ?? '—')}</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">{String(room.capacity ?? '—')}</td>
|
||||
<td className="p-3 text-right">
|
||||
{room.price_per_night != null
|
||||
? `${Number(room.price_per_night).toFixed(2)} €`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
{room.is_active !== false ? '✓' : '✗'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { ClipboardCheck, Calendar } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string; courseId: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function AttendancePage({ params, searchParams }: PageProps) {
|
||||
const { account, courseId } = await params;
|
||||
const search = await searchParams;
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createCourseManagementApi(client);
|
||||
|
||||
const [course, sessions, participants] = await Promise.all([
|
||||
api.getCourse(courseId),
|
||||
api.getSessions(courseId),
|
||||
api.getParticipants(courseId),
|
||||
]);
|
||||
|
||||
if (!course) return <div>Kurs nicht gefunden</div>;
|
||||
|
||||
const selectedSessionId = (search.session as string) ?? (sessions.length > 0 ? String((sessions[0] as Record<string, unknown>).id) : null);
|
||||
|
||||
const attendance = selectedSessionId
|
||||
? await api.getAttendance(selectedSessionId)
|
||||
: [];
|
||||
|
||||
const attendanceMap = new Map(
|
||||
attendance.map((a: Record<string, unknown>) => [String(a.participant_id), Boolean(a.present)]),
|
||||
);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Anwesenheit">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Anwesenheit</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{String((course as Record<string, unknown>).name)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Session Selector */}
|
||||
{sessions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Calendar className="h-8 w-8" />}
|
||||
title="Keine Termine vorhanden"
|
||||
description="Erstellen Sie zuerst Termine für diesen Kurs."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Termin auswählen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sessions.map((s: Record<string, unknown>) => {
|
||||
const isSelected = String(s.id) === selectedSessionId;
|
||||
return (
|
||||
<a
|
||||
key={String(s.id)}
|
||||
href={`/home/${account}/courses/${courseId}/attendance?session=${String(s.id)}`}
|
||||
>
|
||||
<Badge variant={isSelected ? 'default' : 'outline'} className="cursor-pointer px-3 py-1">
|
||||
{s.session_date
|
||||
? new Date(String(s.session_date)).toLocaleDateString('de-DE')
|
||||
: String(s.id)}
|
||||
</Badge>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Attendance Grid */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ClipboardCheck className="h-5 w-5" />
|
||||
Anwesenheitsliste
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{participants.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
Keine Teilnehmer in diesem Kurs
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Teilnehmer</th>
|
||||
<th className="p-3 text-center font-medium">Anwesend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{participants.map((p: Record<string, unknown>) => (
|
||||
<tr key={String(p.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">
|
||||
{String(p.last_name ?? '')}, {String(p.first_name ?? '')}
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked={attendanceMap.get(String(p.id)) ?? false}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
aria-label={`Anwesenheit ${String(p.last_name)}`}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
188
apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx
Normal file
188
apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { GraduationCap, Users, Calendar, Euro, User, Clock } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string; courseId: string }>;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
planned: 'Geplant', open: 'Offen', running: 'Laufend',
|
||||
completed: 'Abgeschlossen', cancelled: 'Abgesagt',
|
||||
};
|
||||
|
||||
const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'outline' | 'destructive'> = {
|
||||
planned: 'secondary', open: 'default', running: 'info',
|
||||
completed: 'outline', cancelled: 'destructive',
|
||||
};
|
||||
|
||||
export default async function CourseDetailPage({ params }: PageProps) {
|
||||
const { account, courseId } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createCourseManagementApi(client);
|
||||
|
||||
const [course, participants, sessions] = await Promise.all([
|
||||
api.getCourse(courseId),
|
||||
api.getParticipants(courseId),
|
||||
api.getSessions(courseId),
|
||||
]);
|
||||
|
||||
if (!course) return <div>Kurs nicht gefunden</div>;
|
||||
|
||||
const c = course as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={String(c.name)}>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<GraduationCap className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Name</p>
|
||||
<p className="font-semibold">{String(c.name)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<Clock className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Status</p>
|
||||
<Badge variant={STATUS_VARIANT[String(c.status)] ?? 'secondary'}>
|
||||
{STATUS_LABEL[String(c.status)] ?? String(c.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Dozent</p>
|
||||
<p className="font-semibold">{String(c.instructor_id ?? '—')}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<Calendar className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Beginn – Ende</p>
|
||||
<p className="font-semibold">
|
||||
{c.start_date ? new Date(String(c.start_date)).toLocaleDateString('de-DE') : '—'}
|
||||
{' – '}
|
||||
{c.end_date ? new Date(String(c.end_date)).toLocaleDateString('de-DE') : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<Euro className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Gebühr</p>
|
||||
<p className="font-semibold">
|
||||
{c.fee != null ? `${Number(c.fee).toFixed(2)} €` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Teilnehmer</p>
|
||||
<p className="font-semibold">
|
||||
{participants.length} / {String(c.capacity ?? '∞')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Teilnehmer Section */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Teilnehmer</CardTitle>
|
||||
<Link href={`/home/${account}/courses/${courseId}/participants`}>
|
||||
<Button variant="outline" size="sm">Alle anzeigen</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-left font-medium">Datum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{participants.length === 0 ? (
|
||||
<tr><td colSpan={4} className="p-6 text-center text-muted-foreground">Keine Teilnehmer</td></tr>
|
||||
) : participants.map((p: Record<string, unknown>) => (
|
||||
<tr key={String(p.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">{String(p.last_name ?? '')}, {String(p.first_name ?? '')}</td>
|
||||
<td className="p-3">{String(p.email ?? '—')}</td>
|
||||
<td className="p-3"><Badge variant="outline">{String(p.status ?? '—')}</Badge></td>
|
||||
<td className="p-3">{p.enrolled_at ? new Date(String(p.enrolled_at)).toLocaleDateString('de-DE') : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Termine Section */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Termine</CardTitle>
|
||||
<Link href={`/home/${account}/courses/${courseId}/attendance`}>
|
||||
<Button variant="outline" size="sm">Anwesenheit</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Datum</th>
|
||||
<th className="p-3 text-left font-medium">Beginn</th>
|
||||
<th className="p-3 text-left font-medium">Ende</th>
|
||||
<th className="p-3 text-left font-medium">Abgesagt?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions.length === 0 ? (
|
||||
<tr><td colSpan={4} className="p-6 text-center text-muted-foreground">Keine Termine</td></tr>
|
||||
) : sessions.map((s: Record<string, unknown>) => (
|
||||
<tr key={String(s.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3">{s.session_date ? new Date(String(s.session_date)).toLocaleDateString('de-DE') : '—'}</td>
|
||||
<td className="p-3">{String(s.start_time ?? '—')}</td>
|
||||
<td className="p-3">{String(s.end_time ?? '—')}</td>
|
||||
<td className="p-3">{s.cancelled ? <Badge variant="destructive">Ja</Badge> : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Plus, Users } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string; courseId: string }>;
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'outline' | 'destructive'> = {
|
||||
enrolled: 'default',
|
||||
waitlisted: 'secondary',
|
||||
cancelled: 'destructive',
|
||||
completed: 'outline',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
enrolled: 'Angemeldet',
|
||||
waitlisted: 'Warteliste',
|
||||
cancelled: 'Abgemeldet',
|
||||
completed: 'Abgeschlossen',
|
||||
};
|
||||
|
||||
export default async function ParticipantsPage({ params }: PageProps) {
|
||||
const { account, courseId } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createCourseManagementApi(client);
|
||||
|
||||
const [course, participants] = await Promise.all([
|
||||
api.getCourse(courseId),
|
||||
api.getParticipants(courseId),
|
||||
]);
|
||||
|
||||
if (!course) return <div>Kurs nicht gefunden</div>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Teilnehmer">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Teilnehmer</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{String((course as Record<string, unknown>).name)} — {participants.length} Teilnehmer
|
||||
</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Teilnehmer anmelden
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{participants.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Users className="h-8 w-8" />}
|
||||
title="Keine Teilnehmer"
|
||||
description="Melden Sie den ersten Teilnehmer für diesen Kurs an."
|
||||
actionLabel="Teilnehmer anmelden"
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Teilnehmer ({participants.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
||||
<th className="p-3 text-left font-medium">Telefon</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-left font-medium">Anmeldedatum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{participants.map((p: Record<string, unknown>) => (
|
||||
<tr key={String(p.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">
|
||||
{String(p.last_name ?? '')}, {String(p.first_name ?? '')}
|
||||
</td>
|
||||
<td className="p-3">{String(p.email ?? '—')}</td>
|
||||
<td className="p-3">{String(p.phone ?? '—')}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={STATUS_VARIANT[String(p.status)] ?? 'secondary'}>
|
||||
{STATUS_LABEL[String(p.status)] ?? String(p.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{p.enrolled_at
|
||||
? new Date(String(p.enrolled_at)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
248
apps/web/app/[locale]/home/[account]/courses/calendar/page.tsx
Normal file
248
apps/web/app/[locale]/home/[account]/courses/calendar/page.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'Januar',
|
||||
'Februar',
|
||||
'März',
|
||||
'April',
|
||||
'Mai',
|
||||
'Juni',
|
||||
'Juli',
|
||||
'August',
|
||||
'September',
|
||||
'Oktober',
|
||||
'November',
|
||||
'Dezember',
|
||||
];
|
||||
|
||||
function getDaysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
function getFirstWeekday(year: number, month: number): number {
|
||||
const day = new Date(year, month, 1).getDay();
|
||||
return day === 0 ? 6 : day - 1;
|
||||
}
|
||||
|
||||
export default async function CourseCalendarPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createCourseManagementApi(client);
|
||||
const courses = await api.listCourses(acct.id, { page: 1, pageSize: 100 });
|
||||
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
const daysInMonth = getDaysInMonth(year, month);
|
||||
const firstWeekday = getFirstWeekday(year, month);
|
||||
|
||||
// Build set of days that have running courses
|
||||
const courseDates = new Set<number>();
|
||||
|
||||
for (const course of courses.data) {
|
||||
const c = course as Record<string, unknown>;
|
||||
if (c.status === 'cancelled') continue;
|
||||
const startDate = c.start_date ? new Date(String(c.start_date)) : null;
|
||||
const endDate = c.end_date ? new Date(String(c.end_date)) : null;
|
||||
|
||||
if (!startDate) continue;
|
||||
|
||||
const courseStart = startDate;
|
||||
const courseEnd = endDate ?? startDate;
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dayDate = new Date(year, month, d);
|
||||
if (dayDate >= courseStart && dayDate <= courseEnd) {
|
||||
courseDates.add(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build calendar grid
|
||||
const cells: Array<{ day: number | null; hasCourse: boolean; isToday: boolean }> = [];
|
||||
|
||||
for (let i = 0; i < firstWeekday; i++) {
|
||||
cells.push({ day: null, hasCourse: false, isToday: false });
|
||||
}
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
cells.push({
|
||||
day: d,
|
||||
hasCourse: courseDates.has(d),
|
||||
isToday: d === now.getDate() && month === now.getMonth() && year === now.getFullYear(),
|
||||
});
|
||||
}
|
||||
|
||||
while (cells.length % 7 !== 0) {
|
||||
cells.push({ day: null, hasCourse: false, isToday: false });
|
||||
}
|
||||
|
||||
const activeCourses = courses.data.filter(
|
||||
(c: Record<string, unknown>) =>
|
||||
c.status === 'open' || c.status === 'running',
|
||||
);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Kurskalender">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/home/${account}/courses`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Kurskalender</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Kurstermine im Überblick
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Month Calendar */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" size="icon" disabled>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<CardTitle>
|
||||
{MONTH_NAMES[month]} {year}
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="icon" disabled>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Weekday Header */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
{WEEKDAYS.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="text-center text-xs font-medium text-muted-foreground py-2"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{cells.map((cell, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`relative flex h-12 items-center justify-center rounded-md text-sm transition-colors ${
|
||||
cell.day === null
|
||||
? 'bg-transparent'
|
||||
: cell.hasCourse
|
||||
? 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 font-semibold'
|
||||
: 'bg-muted/30 hover:bg-muted/50'
|
||||
} ${cell.isToday ? 'ring-2 ring-primary ring-offset-1' : ''}`}
|
||||
>
|
||||
{cell.day !== null && (
|
||||
<>
|
||||
<span>{cell.day}</span>
|
||||
{cell.hasCourse && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-3 rounded-sm bg-emerald-500/15" />
|
||||
Kurstag
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-3 rounded-sm bg-muted/30" />
|
||||
Frei
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-3 rounded-sm ring-2 ring-primary" />
|
||||
Heute
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Courses this Month */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Aktive Kurse ({activeCourses.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeCourses.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Keine aktiven Kurse in diesem Monat.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{activeCourses.map((course: Record<string, unknown>) => (
|
||||
<div
|
||||
key={String(course.id)}
|
||||
className="flex items-center justify-between rounded-md border p-3"
|
||||
>
|
||||
<div>
|
||||
<Link
|
||||
href={`/home/${account}/courses/${String(course.id)}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{String(course.name)}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{course.start_date
|
||||
? new Date(String(course.start_date)).toLocaleDateString('de-DE')
|
||||
: '—'}{' '}
|
||||
–{' '}
|
||||
{course.end_date
|
||||
? new Date(String(course.end_date)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={String(course.status) === 'running' ? 'info' : 'default'}>
|
||||
{String(course.status) === 'running' ? 'Laufend' : 'Offen'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { FolderTree, Plus } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function CategoriesPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createCourseManagementApi(client);
|
||||
const categories = await api.listCategories(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Kategorien">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Kategorien</h1>
|
||||
<p className="text-muted-foreground">Kurskategorien verwalten</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neue Kategorie
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{categories.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FolderTree className="h-8 w-8" />}
|
||||
title="Keine Kategorien vorhanden"
|
||||
description="Erstellen Sie Ihre erste Kurskategorie."
|
||||
actionLabel="Neue Kategorie"
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Kategorien ({categories.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">Beschreibung</th>
|
||||
<th className="p-3 text-left font-medium">Übergeordnet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.map((cat: Record<string, unknown>) => (
|
||||
<tr key={String(cat.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">{String(cat.name)}</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{String(cat.description ?? '—')}
|
||||
</td>
|
||||
<td className="p-3">{String(cat.parent_id ?? '—')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { GraduationCap, Plus } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function InstructorsPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createCourseManagementApi(client);
|
||||
const instructors = await api.listInstructors(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Dozenten">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Dozenten</h1>
|
||||
<p className="text-muted-foreground">Dozentenpool verwalten</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Dozent
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{instructors.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<GraduationCap className="h-8 w-8" />}
|
||||
title="Keine Dozenten vorhanden"
|
||||
description="Fügen Sie Ihren ersten Dozenten hinzu."
|
||||
actionLabel="Neuer Dozent"
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Dozenten ({instructors.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
||||
<th className="p-3 text-left font-medium">Telefon</th>
|
||||
<th className="p-3 text-left font-medium">Qualifikation</th>
|
||||
<th className="p-3 text-right font-medium">Stundensatz</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{instructors.map((inst: Record<string, unknown>) => (
|
||||
<tr key={String(inst.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">
|
||||
{String(inst.last_name ?? '')}, {String(inst.first_name ?? '')}
|
||||
</td>
|
||||
<td className="p-3">{String(inst.email ?? '—')}</td>
|
||||
<td className="p-3">{String(inst.phone ?? '—')}</td>
|
||||
<td className="p-3">{String(inst.qualification ?? '—')}</td>
|
||||
<td className="p-3 text-right">
|
||||
{inst.hourly_rate != null
|
||||
? `${Number(inst.hourly_rate).toFixed(2)} €`
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { MapPin, Plus } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function LocationsPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createCourseManagementApi(client);
|
||||
const locations = await api.listLocations(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Orte">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Orte</h1>
|
||||
<p className="text-muted-foreground">Kurs- und Veranstaltungsorte verwalten</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Ort
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{locations.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<MapPin className="h-8 w-8" />}
|
||||
title="Keine Orte vorhanden"
|
||||
description="Fügen Sie Ihren ersten Veranstaltungsort hinzu."
|
||||
actionLabel="Neuer Ort"
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Orte ({locations.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">Adresse</th>
|
||||
<th className="p-3 text-left font-medium">Raum</th>
|
||||
<th className="p-3 text-right font-medium">Kapazität</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{locations.map((loc: Record<string, unknown>) => (
|
||||
<tr key={String(loc.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">{String(loc.name)}</td>
|
||||
<td className="p-3">
|
||||
{[loc.street, loc.postal_code, loc.city]
|
||||
.filter(Boolean)
|
||||
.map(String)
|
||||
.join(', ') || '—'}
|
||||
</td>
|
||||
<td className="p-3">{String(loc.room ?? '—')}</td>
|
||||
<td className="p-3 text-right">{String(loc.capacity ?? '—')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
191
apps/web/app/[locale]/home/[account]/courses/new/page.tsx
Normal file
191
apps/web/app/[locale]/home/[account]/courses/new/page.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { Textarea } from '@kit/ui/textarea';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewCoursePage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neuer Kurs">
|
||||
<div className="flex w-full max-w-3xl flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/home/${account}/courses`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Zurück
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Neuer Kurs</h1>
|
||||
<p className="text-muted-foreground">Kurs anlegen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-col gap-6">
|
||||
{/* Grunddaten */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Grunddaten</CardTitle>
|
||||
<CardDescription>
|
||||
Allgemeine Informationen zum Kurs
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="courseNumber">Kursnummer</Label>
|
||||
<Input
|
||||
id="courseNumber"
|
||||
name="courseNumber"
|
||||
placeholder="z.B. K-2025-001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Kursname</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="z.B. Töpfern für Anfänger"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Beschreibung</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Kursbeschreibung…"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Zeitplan */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Zeitplan</CardTitle>
|
||||
<CardDescription>Beginn und Ende des Kurses</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="startDate">Beginn</Label>
|
||||
<Input
|
||||
id="startDate"
|
||||
name="startDate"
|
||||
type="date"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="endDate">Ende</Label>
|
||||
<Input id="endDate" name="endDate" type="date" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Kapazität */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kapazität</CardTitle>
|
||||
<CardDescription>
|
||||
Teilnehmer und Gebühren
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="capacity">Max. Teilnehmer</Label>
|
||||
<Input
|
||||
id="capacity"
|
||||
name="capacity"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="minParticipants">Min. Teilnehmer</Label>
|
||||
<Input
|
||||
id="minParticipants"
|
||||
name="minParticipants"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="fee">Gebühr (€)</Label>
|
||||
<Input
|
||||
id="fee"
|
||||
name="fee"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Zuordnung */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Zuordnung</CardTitle>
|
||||
<CardDescription>Status des Kurses</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
defaultValue="planned"
|
||||
>
|
||||
<option value="planned">Geplant</option>
|
||||
<option value="open">Offen</option>
|
||||
<option value="running">Laufend</option>
|
||||
<option value="completed">Abgeschlossen</option>
|
||||
<option value="cancelled">Abgesagt</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Link href={`/home/${account}/courses`}>
|
||||
<Button variant="outline" type="button">
|
||||
Abbrechen
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit">Kurs erstellen</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
180
apps/web/app/[locale]/home/[account]/courses/page.tsx
Normal file
180
apps/web/app/[locale]/home/[account]/courses/page.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { GraduationCap, Plus, Users, Calendar, Euro } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
const STATUS_BADGE_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
planned: 'secondary',
|
||||
open: 'default',
|
||||
running: 'info',
|
||||
completed: 'outline',
|
||||
cancelled: 'destructive',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
planned: 'Geplant',
|
||||
open: 'Offen',
|
||||
running: 'Laufend',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Abgesagt',
|
||||
};
|
||||
|
||||
export default async function CoursesPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createCourseManagementApi(client);
|
||||
|
||||
const [courses, stats] = await Promise.all([
|
||||
api.listCourses(acct.id, { page: 1 }),
|
||||
api.getStatistics(acct.id),
|
||||
]);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Kurse">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Kurse</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Kursangebot verwalten
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link href={`/home/${account}/courses/new`}>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Kurs
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title="Gesamt"
|
||||
value={stats.totalCourses}
|
||||
icon={<GraduationCap className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Aktiv"
|
||||
value={stats.openCourses}
|
||||
icon={<Calendar className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Abgeschlossen"
|
||||
value={stats.completedCourses}
|
||||
icon={<GraduationCap className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Teilnehmer"
|
||||
value={stats.totalParticipants}
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table or Empty State */}
|
||||
{courses.data.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<GraduationCap className="h-8 w-8" />}
|
||||
title="Keine Kurse vorhanden"
|
||||
description="Erstellen Sie Ihren ersten Kurs, um loszulegen."
|
||||
actionLabel="Neuer Kurs"
|
||||
actionHref={`/home/${account}/courses/new`}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Kurse ({courses.total})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Kursnr.</th>
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">Beginn</th>
|
||||
<th className="p-3 text-left font-medium">Ende</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-right font-medium">Teilnehmer</th>
|
||||
<th className="p-3 text-right font-medium">Gebühr</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{courses.data.map((course: Record<string, unknown>) => (
|
||||
<tr key={String(course.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-mono text-xs">
|
||||
{String(course.course_number ?? '—')}
|
||||
</td>
|
||||
<td className="p-3 font-medium">
|
||||
<Link
|
||||
href={`/home/${account}/courses/${String(course.id)}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{String(course.name)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{course.start_date
|
||||
? new Date(String(course.start_date)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{course.end_date
|
||||
? new Date(String(course.end_date)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={STATUS_BADGE_VARIANT[String(course.status)] ?? 'secondary'}
|
||||
>
|
||||
{STATUS_LABEL[String(course.status)] ?? String(course.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{String(course.capacity ?? '—')}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{course.fee != null
|
||||
? `${Number(course.fee).toFixed(2)} €`
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
GraduationCap,
|
||||
Users,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
BarChart3,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function CourseStatisticsPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createCourseManagementApi(client);
|
||||
const stats = await api.getStatistics(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Kurs-Statistiken">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Statistiken</h1>
|
||||
<p className="text-muted-foreground">Übersicht über das Kursangebot</p>
|
||||
</div>
|
||||
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title="Kurse gesamt"
|
||||
value={stats.totalCourses}
|
||||
icon={<GraduationCap className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Aktive Kurse"
|
||||
value={stats.openCourses}
|
||||
icon={<Calendar className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Teilnehmer gesamt"
|
||||
value={stats.totalParticipants}
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Abgeschlossen"
|
||||
value={stats.completedCourses}
|
||||
icon={<TrendingUp className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chart Placeholder */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
Kursauslastung
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-64 items-center justify-center rounded-md border border-dashed text-muted-foreground">
|
||||
Diagramm wird hier angezeigt
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
Anmeldungen pro Monat
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-64 items-center justify-center rounded-md border border-dashed text-muted-foreground">
|
||||
Diagramm wird hier angezeigt
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
157
apps/web/app/[locale]/home/[account]/documents/generate/page.tsx
Normal file
157
apps/web/app/[locale]/home/[account]/documents/generate/page.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft, FileDown } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<{ type?: string }>;
|
||||
}
|
||||
|
||||
const DOCUMENT_LABELS: Record<string, string> = {
|
||||
'member-card': 'Mitgliedsausweis',
|
||||
invoice: 'Rechnung',
|
||||
labels: 'Etiketten',
|
||||
report: 'Bericht',
|
||||
letter: 'Brief',
|
||||
certificate: 'Zertifikat',
|
||||
};
|
||||
|
||||
export default async function GenerateDocumentPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
const { account } = await params;
|
||||
const { type } = await searchParams;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const selectedType = type ?? 'member-card';
|
||||
const selectedLabel = DOCUMENT_LABELS[selectedType] ?? 'Dokument';
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Dokument generieren">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Back link */}
|
||||
<div>
|
||||
<Link
|
||||
href={`/home/${account}/documents`}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Zurück zu Dokumente
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>{selectedLabel} generieren</CardTitle>
|
||||
<CardDescription>
|
||||
Wählen Sie den Dokumenttyp und die gewünschten Optionen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-5">
|
||||
{/* Document Type */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="documentType">Dokumenttyp</Label>
|
||||
<select
|
||||
id="documentType"
|
||||
name="documentType"
|
||||
defaultValue={selectedType}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="member-card">Mitgliedsausweis</option>
|
||||
<option value="invoice">Rechnung</option>
|
||||
<option value="labels">Etiketten</option>
|
||||
<option value="report">Bericht</option>
|
||||
<option value="letter">Brief</option>
|
||||
<option value="certificate">Zertifikat</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="title">Titel / Bezeichnung</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
placeholder={`z.B. ${selectedLabel} für Max Mustermann`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Format */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="format">Format</Label>
|
||||
<select
|
||||
id="format"
|
||||
name="format"
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="A4">A4</option>
|
||||
<option value="A5">A5</option>
|
||||
<option value="letter">Letter</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="orientation">Ausrichtung</Label>
|
||||
<select
|
||||
id="orientation"
|
||||
name="orientation"
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="portrait">Hochformat</option>
|
||||
<option value="landscape">Querformat</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="rounded-md bg-muted/50 p-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<strong>Hinweis:</strong> Die Dokumentgenerierung verwendet
|
||||
Ihre gespeicherten Vorlagen. Stellen Sie sicher, dass eine
|
||||
passende Vorlage für den gewählten Dokumenttyp existiert.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-between">
|
||||
<Link href={`/home/${account}/documents`}>
|
||||
<Button variant="outline">Abbrechen</Button>
|
||||
</Link>
|
||||
<Button type="submit">
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
Generieren
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
137
apps/web/app/[locale]/home/[account]/documents/page.tsx
Normal file
137
apps/web/app/[locale]/home/[account]/documents/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
CreditCard,
|
||||
FileText,
|
||||
Tag,
|
||||
BarChart3,
|
||||
Mail,
|
||||
Award,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
const DOCUMENT_TYPES = [
|
||||
{
|
||||
id: 'member-card',
|
||||
title: 'Mitgliedsausweis',
|
||||
description:
|
||||
'Mitgliedsausweise mit Foto, Name und Mitgliedsnummer generieren.',
|
||||
icon: CreditCard,
|
||||
color: 'text-blue-600 bg-blue-50',
|
||||
},
|
||||
{
|
||||
id: 'invoice',
|
||||
title: 'Rechnung',
|
||||
description:
|
||||
'Professionelle Rechnungen im PDF-Format mit Logo und Positionen.',
|
||||
icon: FileText,
|
||||
color: 'text-green-600 bg-green-50',
|
||||
},
|
||||
{
|
||||
id: 'labels',
|
||||
title: 'Etiketten',
|
||||
description:
|
||||
'Adressetiketten für Serienbriefe im Avery-Format drucken.',
|
||||
icon: Tag,
|
||||
color: 'text-orange-600 bg-orange-50',
|
||||
},
|
||||
{
|
||||
id: 'report',
|
||||
title: 'Bericht',
|
||||
description:
|
||||
'Statistische Auswertungen und Berichte als PDF oder Excel.',
|
||||
icon: BarChart3,
|
||||
color: 'text-purple-600 bg-purple-50',
|
||||
},
|
||||
{
|
||||
id: 'letter',
|
||||
title: 'Brief',
|
||||
description:
|
||||
'Serienbriefe mit personalisierten Platzhaltern erstellen.',
|
||||
icon: Mail,
|
||||
color: 'text-rose-600 bg-rose-50',
|
||||
},
|
||||
{
|
||||
id: 'certificate',
|
||||
title: 'Zertifikat',
|
||||
description:
|
||||
'Teilnahmebescheinigungen und Zertifikate mit Unterschrift.',
|
||||
icon: Award,
|
||||
color: 'text-amber-600 bg-amber-50',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default async function DocumentsPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Dokumente">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Dokumente</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Dokumente erstellen und verwalten
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/home/${account}/documents/templates`}>
|
||||
<Button variant="outline">Vorlagen</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document Type Grid */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{DOCUMENT_TYPES.map((docType) => {
|
||||
const Icon = docType.icon;
|
||||
return (
|
||||
<Card key={docType.id} className="flex flex-col">
|
||||
<CardHeader className="flex flex-row items-start gap-4 space-y-0">
|
||||
<div className={`rounded-lg p-3 ${docType.color}`}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-base">{docType.title}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col justify-between gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{docType.description}
|
||||
</p>
|
||||
<Link
|
||||
href={`/home/${account}/documents/generate?type=${docType.id}`}
|
||||
>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
Erstellen
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { FileText, Plus } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function DocumentTemplatesPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
// Document templates are stored locally for now — placeholder for future DB integration
|
||||
const templates: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
}> = [];
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Dokumentvorlagen">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Dokumentvorlagen</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Vorlagen für Mitgliedsausweise, Rechnungen, Etiketten und mehr
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neue Vorlage
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table or Empty State */}
|
||||
{templates.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FileText className="h-8 w-8" />}
|
||||
title="Keine Vorlagen vorhanden"
|
||||
description="Erstellen Sie Ihre erste Dokumentvorlage, um Mitgliedsausweise, Rechnungen und mehr zu generieren."
|
||||
actionLabel="Neue Vorlage"
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Vorlagen ({templates.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">Typ</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
Beschreibung
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templates.map((template) => (
|
||||
<tr
|
||||
key={template.id}
|
||||
className="border-b hover:bg-muted/30"
|
||||
>
|
||||
<td className="p-3 font-medium">{template.name}</td>
|
||||
<td className="p-3">{template.type}</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{template.description}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
182
apps/web/app/[locale]/home/[account]/events/[eventId]/page.tsx
Normal file
182
apps/web/app/[locale]/home/[account]/events/[eventId]/page.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
CalendarDays,
|
||||
MapPin,
|
||||
Users,
|
||||
Euro,
|
||||
Clock,
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createEventManagementApi } from '@kit/event-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string; eventId: string }>;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
published: 'Veröffentlicht',
|
||||
registration_open: 'Anmeldung offen',
|
||||
registration_closed: 'Anmeldung geschlossen',
|
||||
cancelled: 'Abgesagt',
|
||||
completed: 'Abgeschlossen',
|
||||
};
|
||||
|
||||
const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'outline' | 'destructive'> = {
|
||||
draft: 'secondary',
|
||||
published: 'default',
|
||||
registration_open: 'info',
|
||||
registration_closed: 'outline',
|
||||
cancelled: 'destructive',
|
||||
completed: 'outline',
|
||||
};
|
||||
|
||||
export default async function EventDetailPage({ params }: PageProps) {
|
||||
const { account, eventId } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createEventManagementApi(client);
|
||||
|
||||
const [event, registrations] = await Promise.all([
|
||||
api.getEvent(eventId),
|
||||
api.getRegistrations(eventId),
|
||||
]);
|
||||
|
||||
if (!event) return <div>Veranstaltung nicht gefunden</div>;
|
||||
|
||||
const e = event as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={String(e.name)}>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{String(e.name)}</h1>
|
||||
<Badge variant={STATUS_VARIANT[String(e.status)] ?? 'secondary'} className="mt-1">
|
||||
{STATUS_LABEL[String(e.status)] ?? String(e.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Anmelden
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Detail Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<CalendarDays className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Datum</p>
|
||||
<p className="font-semibold">
|
||||
{e.event_date
|
||||
? new Date(String(e.event_date)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<Clock className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Uhrzeit</p>
|
||||
<p className="font-semibold">
|
||||
{String(e.start_time ?? '—')} – {String(e.end_time ?? '—')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<MapPin className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Ort</p>
|
||||
<p className="font-semibold">{String(e.location ?? '—')}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Anmeldungen</p>
|
||||
<p className="font-semibold">
|
||||
{registrations.length} / {String(e.capacity ?? '∞')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{e.description ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Beschreibung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{String(e.description)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Registrations Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anmeldungen ({registrations.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{registrations.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
Noch keine Anmeldungen
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
||||
<th className="p-3 text-left font-medium">Elternteil</th>
|
||||
<th className="p-3 text-left font-medium">Datum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{registrations.map((reg: Record<string, unknown>) => (
|
||||
<tr key={String(reg.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">
|
||||
{String(reg.last_name ?? '')}, {String(reg.first_name ?? '')}
|
||||
</td>
|
||||
<td className="p-3">{String(reg.email ?? '—')}</td>
|
||||
<td className="p-3">{String(reg.parent_name ?? '—')}</td>
|
||||
<td className="p-3">
|
||||
{reg.created_at
|
||||
? new Date(String(reg.created_at)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Ticket, Plus } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createEventManagementApi } from '@kit/event-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function HolidayPassesPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createEventManagementApi(client);
|
||||
const passes = await api.listHolidayPasses(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Ferienpässe">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Ferienpässe</h1>
|
||||
<p className="text-muted-foreground">Ferienpässe und Ferienprogramme verwalten</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Ferienpass
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{passes.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Ticket className="h-8 w-8" />}
|
||||
title="Keine Ferienpässe vorhanden"
|
||||
description="Erstellen Sie Ihren ersten Ferienpass."
|
||||
actionLabel="Neuer Ferienpass"
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Ferienpässe ({passes.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">Jahr</th>
|
||||
<th className="p-3 text-right font-medium">Preis</th>
|
||||
<th className="p-3 text-left font-medium">Gültig von</th>
|
||||
<th className="p-3 text-left font-medium">Gültig bis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{passes.map((pass: Record<string, unknown>) => (
|
||||
<tr key={String(pass.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">{String(pass.name)}</td>
|
||||
<td className="p-3">{String(pass.year ?? '—')}</td>
|
||||
<td className="p-3 text-right">
|
||||
{pass.price != null
|
||||
? `${Number(pass.price).toFixed(2)} €`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{pass.valid_from
|
||||
? new Date(String(pass.valid_from)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{pass.valid_until
|
||||
? new Date(String(pass.valid_until)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
301
apps/web/app/[locale]/home/[account]/events/new/page.tsx
Normal file
301
apps/web/app/[locale]/home/[account]/events/new/page.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
MapPin,
|
||||
Phone,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewEventPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neue Veranstaltung">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/home/${account}/events`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Neue Veranstaltung</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Veranstaltung oder Ferienprogramm anlegen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-col gap-6">
|
||||
{/* Grunddaten */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CalendarDays className="h-5 w-5" />
|
||||
Grunddaten
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Name und Beschreibung der Veranstaltung
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="name" className="text-sm font-medium">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="z.B. Sommerfest 2025"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="description" className="text-sm font-medium">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows={4}
|
||||
placeholder="Beschreiben Sie die Veranstaltung…"
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Datum & Ort */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" />
|
||||
Datum & Ort
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Zeitraum und Veranstaltungsort
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="event_date" className="text-sm font-medium">
|
||||
Veranstaltungsdatum
|
||||
</label>
|
||||
<input
|
||||
id="event_date"
|
||||
name="event_date"
|
||||
type="date"
|
||||
required
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="event_time" className="text-sm font-medium">
|
||||
Uhrzeit
|
||||
</label>
|
||||
<input
|
||||
id="event_time"
|
||||
name="event_time"
|
||||
type="time"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="end_date" className="text-sm font-medium">
|
||||
Enddatum
|
||||
</label>
|
||||
<input
|
||||
id="end_date"
|
||||
name="end_date"
|
||||
type="date"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="location" className="text-sm font-medium">
|
||||
Ort
|
||||
</label>
|
||||
<input
|
||||
id="location"
|
||||
name="location"
|
||||
type="text"
|
||||
placeholder="z.B. Gemeindehaus, Turnhalle"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Teilnehmer & Kosten */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Teilnehmer & Kosten
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Kapazität, Alter und Teilnahmegebühr
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="capacity" className="text-sm font-medium">
|
||||
Kapazität
|
||||
</label>
|
||||
<input
|
||||
id="capacity"
|
||||
name="capacity"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Max. Teilnehmer"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="min_age" className="text-sm font-medium">
|
||||
Mindestalter
|
||||
</label>
|
||||
<input
|
||||
id="min_age"
|
||||
name="min_age"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="z.B. 6"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="max_age" className="text-sm font-medium">
|
||||
Höchstalter
|
||||
</label>
|
||||
<input
|
||||
id="max_age"
|
||||
name="max_age"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="z.B. 16"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="fee" className="text-sm font-medium">
|
||||
Gebühr (€)
|
||||
</label>
|
||||
<input
|
||||
id="fee"
|
||||
name="fee"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
placeholder="0.00"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Kontakt */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Phone className="h-5 w-5" />
|
||||
Kontakt
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Ansprechpartner für die Veranstaltung
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="contact_name" className="text-sm font-medium">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="contact_name"
|
||||
name="contact_name"
|
||||
type="text"
|
||||
placeholder="Vorname Nachname"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="contact_email" className="text-sm font-medium">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="contact_email"
|
||||
name="contact_email"
|
||||
type="email"
|
||||
placeholder="name@example.de"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="contact_phone" className="text-sm font-medium">
|
||||
Telefon
|
||||
</label>
|
||||
<input
|
||||
id="contact_phone"
|
||||
name="contact_phone"
|
||||
type="tel"
|
||||
placeholder="+49 …"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Link href={`/home/${account}/events`}>
|
||||
<Button type="button" variant="outline">
|
||||
Abbrechen
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit">Veranstaltung erstellen</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
180
apps/web/app/[locale]/home/[account]/events/page.tsx
Normal file
180
apps/web/app/[locale]/home/[account]/events/page.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CalendarDays, MapPin, Plus, Users } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createEventManagementApi } from '@kit/event-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
const STATUS_BADGE_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
draft: 'secondary',
|
||||
published: 'default',
|
||||
registration_open: 'info',
|
||||
registration_closed: 'outline',
|
||||
cancelled: 'destructive',
|
||||
completed: 'outline',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
published: 'Veröffentlicht',
|
||||
registration_open: 'Anmeldung offen',
|
||||
registration_closed: 'Anmeldung geschlossen',
|
||||
cancelled: 'Abgesagt',
|
||||
completed: 'Abgeschlossen',
|
||||
};
|
||||
|
||||
export default async function EventsPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createEventManagementApi(client);
|
||||
const events = await api.listEvents(acct.id, { page: 1 });
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Veranstaltungen">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Veranstaltungen</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Veranstaltungen und Ferienprogramme
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link href={`/home/${account}/events/new`}>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neue Veranstaltung
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<StatsCard
|
||||
title="Veranstaltungen"
|
||||
value={events.total}
|
||||
icon={<CalendarDays className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Orte"
|
||||
value={
|
||||
new Set(
|
||||
events.data
|
||||
.map((e: Record<string, unknown>) => e.location)
|
||||
.filter(Boolean),
|
||||
).size
|
||||
}
|
||||
icon={<MapPin className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Kapazität gesamt"
|
||||
value={events.data.reduce(
|
||||
(sum: number, e: Record<string, unknown>) =>
|
||||
sum + (Number(e.capacity) || 0),
|
||||
0,
|
||||
)}
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table or Empty State */}
|
||||
{events.data.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<CalendarDays className="h-8 w-8" />}
|
||||
title="Keine Veranstaltungen vorhanden"
|
||||
description="Erstellen Sie Ihre erste Veranstaltung, um loszulegen."
|
||||
actionLabel="Neue Veranstaltung"
|
||||
actionHref={`/home/${account}/events/new`}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Veranstaltungen ({events.total})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">Datum</th>
|
||||
<th className="p-3 text-left font-medium">Ort</th>
|
||||
<th className="p-3 text-right font-medium">Kapazität</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-right font-medium">Anmeldungen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.data.map((event: Record<string, unknown>) => (
|
||||
<tr
|
||||
key={String(event.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
<Link
|
||||
href={`/home/${account}/events/${String(event.id)}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{String(event.name)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{event.event_date
|
||||
? new Date(String(event.event_date)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{String(event.location ?? '—')}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{event.capacity != null
|
||||
? String(event.capacity)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
STATUS_BADGE_VARIANT[String(event.status)] ?? 'secondary'
|
||||
}
|
||||
>
|
||||
{STATUS_LABEL[String(event.status)] ?? String(event.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">—</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CalendarDays, ClipboardList, Users } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createEventManagementApi } from '@kit/event-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function EventRegistrationsPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createEventManagementApi(client);
|
||||
const events = await api.listEvents(acct.id, { page: 1 });
|
||||
|
||||
// Load registrations for each event in parallel
|
||||
const eventsWithRegistrations = await Promise.all(
|
||||
events.data.map(async (event: Record<string, unknown>) => {
|
||||
const registrations = await api.getRegistrations(String(event.id));
|
||||
return {
|
||||
id: String(event.id),
|
||||
name: String(event.name),
|
||||
eventDate: event.event_date ? String(event.event_date) : null,
|
||||
status: String(event.status ?? 'draft'),
|
||||
capacity: event.capacity != null ? Number(event.capacity) : null,
|
||||
registrationCount: registrations.length,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const totalRegistrations = eventsWithRegistrations.reduce(
|
||||
(sum, e) => sum + e.registrationCount,
|
||||
0,
|
||||
);
|
||||
const eventsWithRegs = eventsWithRegistrations.filter(
|
||||
(e) => e.registrationCount > 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Anmeldungen">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Anmeldungen</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Anmeldungen aller Veranstaltungen im Überblick
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<StatsCard
|
||||
title="Veranstaltungen"
|
||||
value={events.total}
|
||||
icon={<CalendarDays className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Anmeldungen gesamt"
|
||||
value={totalRegistrations}
|
||||
icon={<ClipboardList className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Mit Anmeldungen"
|
||||
value={eventsWithRegs.length}
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Registration Summary Table */}
|
||||
{eventsWithRegistrations.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<ClipboardList className="h-8 w-8" />}
|
||||
title="Keine Veranstaltungen vorhanden"
|
||||
description="Erstellen Sie eine Veranstaltung, um Anmeldungen zu erhalten."
|
||||
actionLabel="Neue Veranstaltung"
|
||||
actionHref={`/home/${account}/events/new`}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Übersicht nach Veranstaltung ({eventsWithRegistrations.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">
|
||||
Veranstaltung
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">Datum</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-right font-medium">Kapazität</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
Anmeldungen
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">Auslastung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{eventsWithRegistrations.map((event) => {
|
||||
const utilization =
|
||||
event.capacity && event.capacity > 0
|
||||
? Math.round(
|
||||
(event.registrationCount / event.capacity) * 100,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={event.id}
|
||||
className="border-b hover:bg-muted/30"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
<Link
|
||||
href={`/home/${account}/events/${event.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{event.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{event.eventDate
|
||||
? new Date(event.eventDate).toLocaleDateString(
|
||||
'de-DE',
|
||||
)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="outline">{event.status}</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{event.capacity ?? '—'}
|
||||
</td>
|
||||
<td className="p-3 text-right font-medium">
|
||||
{event.registrationCount}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{utilization !== null ? (
|
||||
<Badge
|
||||
variant={
|
||||
utilization >= 90
|
||||
? 'destructive'
|
||||
: utilization >= 70
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{utilization}%
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft, Send, CheckCircle } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createFinanceApi } from '@kit/finance/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string; id: string }>;
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
draft: 'secondary',
|
||||
sent: 'default',
|
||||
paid: 'info',
|
||||
overdue: 'destructive',
|
||||
cancelled: 'destructive',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
sent: 'Versendet',
|
||||
paid: 'Bezahlt',
|
||||
overdue: 'Überfällig',
|
||||
cancelled: 'Storniert',
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: unknown) =>
|
||||
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
|
||||
Number(amount),
|
||||
);
|
||||
|
||||
export default async function InvoiceDetailPage({ params }: PageProps) {
|
||||
const { account, id } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createFinanceApi(client);
|
||||
const invoice = await api.getInvoiceWithItems(id);
|
||||
|
||||
if (!invoice) return <div>Rechnung nicht gefunden</div>;
|
||||
|
||||
const status = String(invoice.status);
|
||||
const items = (invoice.items ?? []) as Array<Record<string, unknown>>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Rechnungsdetails">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Back link */}
|
||||
<div>
|
||||
<Link
|
||||
href={`/home/${account}/finance/invoices`}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Zurück zu Rechnungen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Summary Card */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>
|
||||
Rechnung {String(invoice.invoice_number ?? '')}
|
||||
</CardTitle>
|
||||
<Badge variant={STATUS_VARIANT[status] ?? 'secondary'}>
|
||||
{STATUS_LABEL[status] ?? status}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Empfänger
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm font-semibold">
|
||||
{String(invoice.recipient_name ?? '—')}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Rechnungsdatum
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm font-semibold">
|
||||
{invoice.issue_date
|
||||
? new Date(
|
||||
String(invoice.issue_date),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Fälligkeitsdatum
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm font-semibold">
|
||||
{invoice.due_date
|
||||
? new Date(String(invoice.due_date)).toLocaleDateString(
|
||||
'de-DE',
|
||||
)
|
||||
: '—'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Gesamtbetrag
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm font-semibold">
|
||||
{invoice.total_amount != null
|
||||
? formatCurrency(invoice.total_amount)
|
||||
: '—'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-6 flex gap-3">
|
||||
{status === 'draft' && (
|
||||
<Button>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Senden
|
||||
</Button>
|
||||
)}
|
||||
{(status === 'sent' || status === 'overdue') && (
|
||||
<Button variant="outline">
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Bezahlt markieren
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Line Items */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Positionen ({items.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{items.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
Keine Positionen vorhanden.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">
|
||||
Beschreibung
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">Menge</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
Einzelpreis
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">Gesamt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr
|
||||
key={String(item.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
>
|
||||
<td className="p-3">
|
||||
{String(item.description ?? '—')}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{String(item.quantity ?? 0)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{item.unit_price != null
|
||||
? formatCurrency(item.unit_price)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3 text-right font-medium">
|
||||
{item.total_price != null
|
||||
? formatCurrency(item.total_price)
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t bg-muted/30">
|
||||
<td colSpan={3} className="p-3 text-right font-medium">
|
||||
Zwischensumme
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{formatCurrency(invoice.subtotal ?? 0)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={3} className="p-3 text-right font-medium">
|
||||
MwSt. ({Number(invoice.tax_rate ?? 19)}%)
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{formatCurrency(invoice.tax_amount ?? 0)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="border-t font-semibold">
|
||||
<td colSpan={3} className="p-3 text-right">
|
||||
Gesamtbetrag
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{formatCurrency(invoice.total_amount ?? 0)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { Textarea } from '@kit/ui/textarea';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewInvoicePage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neue Rechnung">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Back link */}
|
||||
<div>
|
||||
<Link
|
||||
href={`/home/${account}/finance/invoices`}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Zurück zu Rechnungen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-3xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Neue Rechnung</CardTitle>
|
||||
<CardDescription>
|
||||
Erstellen Sie eine neue Rechnung mit Positionen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-6">
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="invoiceNumber">Rechnungsnummer</Label>
|
||||
<Input
|
||||
id="invoiceNumber"
|
||||
name="invoiceNumber"
|
||||
placeholder="RE-2026-001"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="recipientName">Empfänger</Label>
|
||||
<Input
|
||||
id="recipientName"
|
||||
name="recipientName"
|
||||
placeholder="Max Mustermann"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recipient Address */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="recipientAddress">Empfängeradresse</Label>
|
||||
<Textarea
|
||||
id="recipientAddress"
|
||||
name="recipientAddress"
|
||||
placeholder="Musterstraße 1 12345 Musterstadt"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dates + Tax */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="issueDate">Rechnungsdatum</Label>
|
||||
<Input
|
||||
id="issueDate"
|
||||
name="issueDate"
|
||||
type="date"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="dueDate">Fälligkeitsdatum</Label>
|
||||
<Input
|
||||
id="dueDate"
|
||||
name="dueDate"
|
||||
type="date"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="taxRate">Steuersatz (%)</Label>
|
||||
<Input
|
||||
id="taxRate"
|
||||
name="taxRate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
defaultValue="19"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line Items */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>Positionen</Label>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">
|
||||
Beschreibung
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">Menge</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
Einzelpreis (€)
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">Gesamt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[0, 1, 2].map((idx) => (
|
||||
<tr key={idx} className="border-b">
|
||||
<td className="p-2">
|
||||
<Input
|
||||
name={`items[${idx}].description`}
|
||||
placeholder="Leistungsbeschreibung"
|
||||
className="border-0 shadow-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="w-24 p-2">
|
||||
<Input
|
||||
name={`items[${idx}].quantity`}
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
defaultValue="1"
|
||||
className="border-0 text-right shadow-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="w-32 p-2">
|
||||
<Input
|
||||
name={`items[${idx}].unitPrice`}
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
defaultValue="0.00"
|
||||
className="border-0 text-right shadow-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="w-32 p-3 text-right text-muted-foreground">
|
||||
0,00 €
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="ml-auto flex w-64 flex-col gap-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Zwischensumme</span>
|
||||
<span>0,00 €</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">MwSt. (19%)</span>
|
||||
<span>0,00 €</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t pt-1 font-semibold">
|
||||
<span>Gesamt</span>
|
||||
<span>0,00 €</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-between">
|
||||
<Link href={`/home/${account}/finance/invoices`}>
|
||||
<Button variant="outline">Abbrechen</Button>
|
||||
</Link>
|
||||
<Button type="submit">Rechnung erstellen</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
162
apps/web/app/[locale]/home/[account]/finance/invoices/page.tsx
Normal file
162
apps/web/app/[locale]/home/[account]/finance/invoices/page.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { FileText, Plus } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createFinanceApi } from '@kit/finance/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
draft: 'secondary',
|
||||
sent: 'default',
|
||||
paid: 'info',
|
||||
overdue: 'destructive',
|
||||
cancelled: 'destructive',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
sent: 'Versendet',
|
||||
paid: 'Bezahlt',
|
||||
overdue: 'Überfällig',
|
||||
cancelled: 'Storniert',
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: unknown) =>
|
||||
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
|
||||
Number(amount),
|
||||
);
|
||||
|
||||
export default async function InvoicesPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createFinanceApi(client);
|
||||
const invoices = await api.listInvoices(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Rechnungen">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Rechnungen</h1>
|
||||
<p className="text-muted-foreground">Rechnungen verwalten</p>
|
||||
</div>
|
||||
|
||||
<Link href={`/home/${account}/finance/invoices/new`}>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neue Rechnung
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Table or Empty State */}
|
||||
{invoices.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FileText className="h-8 w-8" />}
|
||||
title="Keine Rechnungen vorhanden"
|
||||
description="Erstellen Sie Ihre erste Rechnung."
|
||||
actionLabel="Neue Rechnung"
|
||||
actionHref={`/home/${account}/finance/invoices/new`}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Rechnungen ({invoices.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Nr.</th>
|
||||
<th className="p-3 text-left font-medium">Empfänger</th>
|
||||
<th className="p-3 text-left font-medium">Datum</th>
|
||||
<th className="p-3 text-left font-medium">Fällig</th>
|
||||
<th className="p-3 text-right font-medium">Betrag</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map((invoice: Record<string, unknown>) => {
|
||||
const status = String(invoice.status);
|
||||
return (
|
||||
<tr
|
||||
key={String(invoice.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
<Link
|
||||
href={`/home/${account}/finance/invoices/${String(invoice.id)}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{String(invoice.invoice_number ?? '—')}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{String(invoice.recipient_name ?? '—')}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{invoice.issue_date
|
||||
? new Date(
|
||||
String(invoice.issue_date),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{invoice.due_date
|
||||
? new Date(
|
||||
String(invoice.due_date),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{invoice.total_amount != null
|
||||
? formatCurrency(invoice.total_amount)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
STATUS_VARIANT[status] ?? 'secondary'
|
||||
}
|
||||
>
|
||||
{STATUS_LABEL[status] ?? status}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
265
apps/web/app/[locale]/home/[account]/finance/page.tsx
Normal file
265
apps/web/app/[locale]/home/[account]/finance/page.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Landmark, FileText, Euro, ArrowRight } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createFinanceApi } from '@kit/finance/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
const BATCH_STATUS_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
draft: 'secondary',
|
||||
ready: 'default',
|
||||
submitted: 'info',
|
||||
completed: 'outline',
|
||||
failed: 'destructive',
|
||||
};
|
||||
|
||||
const BATCH_STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
ready: 'Bereit',
|
||||
submitted: 'Eingereicht',
|
||||
completed: 'Abgeschlossen',
|
||||
failed: 'Fehlgeschlagen',
|
||||
};
|
||||
|
||||
const INVOICE_STATUS_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
draft: 'secondary',
|
||||
sent: 'default',
|
||||
paid: 'info',
|
||||
overdue: 'destructive',
|
||||
cancelled: 'destructive',
|
||||
};
|
||||
|
||||
const INVOICE_STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
sent: 'Versendet',
|
||||
paid: 'Bezahlt',
|
||||
overdue: 'Überfällig',
|
||||
cancelled: 'Storniert',
|
||||
};
|
||||
|
||||
export default async function FinancePage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createFinanceApi(client);
|
||||
|
||||
const [batches, invoices] = await Promise.all([
|
||||
api.listBatches(acct.id),
|
||||
api.listInvoices(acct.id),
|
||||
]);
|
||||
|
||||
const openAmount = invoices
|
||||
.filter(
|
||||
(inv: Record<string, unknown>) =>
|
||||
inv.status === 'sent' || inv.status === 'overdue',
|
||||
)
|
||||
.reduce(
|
||||
(sum: number, inv: Record<string, unknown>) =>
|
||||
sum + (Number(inv.total_amount) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Finanzen">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Finanzen</h1>
|
||||
<p className="text-muted-foreground">
|
||||
SEPA-Einzüge und Rechnungen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<StatsCard
|
||||
title="SEPA-Einzüge"
|
||||
value={batches.length}
|
||||
icon={<Landmark className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Rechnungen"
|
||||
value={invoices.length}
|
||||
icon={<FileText className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Offener Betrag"
|
||||
value={`${openAmount.toFixed(2)} €`}
|
||||
icon={<Euro className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SEPA Batches */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Letzte SEPA-Einzüge</CardTitle>
|
||||
<Link href={`/home/${account}/finance/sepa`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
Alle anzeigen
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{batches.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Landmark className="h-8 w-8" />}
|
||||
title="Keine SEPA-Einzüge"
|
||||
description="Erstellen Sie Ihren ersten SEPA-Einzug."
|
||||
actionLabel="Neuer SEPA-Einzug"
|
||||
actionHref={`/home/${account}/finance/sepa/new`}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-left font-medium">Typ</th>
|
||||
<th className="p-3 text-right font-medium">Betrag</th>
|
||||
<th className="p-3 text-left font-medium">Datum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batches.slice(0, 5).map((batch: Record<string, unknown>) => (
|
||||
<tr
|
||||
key={String(batch.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
>
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
BATCH_STATUS_VARIANT[String(batch.status)] ?? 'secondary'
|
||||
}
|
||||
>
|
||||
{BATCH_STATUS_LABEL[String(batch.status)] ?? String(batch.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{batch.batch_type === 'direct_debit'
|
||||
? 'Lastschrift'
|
||||
: 'Überweisung'}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{batch.total_amount != null
|
||||
? `${Number(batch.total_amount).toFixed(2)} €`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{batch.execution_date
|
||||
? new Date(String(batch.execution_date)).toLocaleDateString('de-DE')
|
||||
: batch.created_at
|
||||
? new Date(String(batch.created_at)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoices */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Letzte Rechnungen</CardTitle>
|
||||
<Link href={`/home/${account}/finance/invoices`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
Alle anzeigen
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{invoices.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FileText className="h-8 w-8" />}
|
||||
title="Keine Rechnungen"
|
||||
description="Erstellen Sie Ihre erste Rechnung."
|
||||
actionLabel="Neue Rechnung"
|
||||
actionHref={`/home/${account}/finance/invoices/new`}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Nr.</th>
|
||||
<th className="p-3 text-left font-medium">Empfänger</th>
|
||||
<th className="p-3 text-right font-medium">Betrag</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.slice(0, 5).map((invoice: Record<string, unknown>) => (
|
||||
<tr
|
||||
key={String(invoice.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
<Link
|
||||
href={`/home/${account}/finance/invoices/${String(invoice.id)}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{String(invoice.invoice_number ?? '—')}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{String(invoice.recipient_name ?? '—')}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{invoice.total_amount != null
|
||||
? `${Number(invoice.total_amount).toFixed(2)} €`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
INVOICE_STATUS_VARIANT[String(invoice.status)] ??
|
||||
'secondary'
|
||||
}
|
||||
>
|
||||
{INVOICE_STATUS_LABEL[String(invoice.status)] ??
|
||||
String(invoice.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
166
apps/web/app/[locale]/home/[account]/finance/payments/page.tsx
Normal file
166
apps/web/app/[locale]/home/[account]/finance/payments/page.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Euro, CreditCard, TrendingUp, ArrowRight } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createFinanceApi } from '@kit/finance/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) =>
|
||||
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
|
||||
amount,
|
||||
);
|
||||
|
||||
export default async function PaymentsPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createFinanceApi(client);
|
||||
|
||||
const [batches, invoices] = await Promise.all([
|
||||
api.listBatches(acct.id),
|
||||
api.listInvoices(acct.id),
|
||||
]);
|
||||
|
||||
const paidInvoices = invoices.filter(
|
||||
(inv: Record<string, unknown>) => inv.status === 'paid',
|
||||
);
|
||||
const openInvoices = invoices.filter(
|
||||
(inv: Record<string, unknown>) =>
|
||||
inv.status === 'sent' || inv.status === 'overdue',
|
||||
);
|
||||
const overdueInvoices = invoices.filter(
|
||||
(inv: Record<string, unknown>) => inv.status === 'overdue',
|
||||
);
|
||||
|
||||
const paidTotal = paidInvoices.reduce(
|
||||
(sum: number, inv: Record<string, unknown>) =>
|
||||
sum + (Number(inv.total_amount) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const openTotal = openInvoices.reduce(
|
||||
(sum: number, inv: Record<string, unknown>) =>
|
||||
sum + (Number(inv.total_amount) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const overdueTotal = overdueInvoices.reduce(
|
||||
(sum: number, inv: Record<string, unknown>) =>
|
||||
sum + (Number(inv.total_amount) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const sepaTotal = batches.reduce(
|
||||
(sum: number, b: Record<string, unknown>) =>
|
||||
sum + (Number(b.total_amount) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Zahlungen">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Zahlungsübersicht</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Zusammenfassung aller Zahlungen und offenen Beträge
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title="Bezahlt"
|
||||
value={formatCurrency(paidTotal)}
|
||||
icon={<Euro className="h-5 w-5" />}
|
||||
description={`${paidInvoices.length} Rechnungen`}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Offen"
|
||||
value={formatCurrency(openTotal)}
|
||||
icon={<CreditCard className="h-5 w-5" />}
|
||||
description={`${openInvoices.length} Rechnungen`}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Überfällig"
|
||||
value={formatCurrency(overdueTotal)}
|
||||
icon={<TrendingUp className="h-5 w-5" />}
|
||||
description={`${overdueInvoices.length} Rechnungen`}
|
||||
/>
|
||||
<StatsCard
|
||||
title="SEPA-Einzüge"
|
||||
value={formatCurrency(sepaTotal)}
|
||||
icon={<Euro className="h-5 w-5" />}
|
||||
description={`${batches.length} Einzüge`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">Offene Rechnungen</CardTitle>
|
||||
<Badge variant={openInvoices.length > 0 ? 'default' : 'secondary'}>
|
||||
{openInvoices.length}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{openInvoices.length > 0
|
||||
? `${openInvoices.length} Rechnungen mit einem Gesamtbetrag von ${formatCurrency(openTotal)} sind offen.`
|
||||
: 'Keine offenen Rechnungen vorhanden.'}
|
||||
</p>
|
||||
<Link href={`/home/${account}/finance/invoices`}>
|
||||
<Button variant="outline" size="sm">
|
||||
Rechnungen anzeigen
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">SEPA-Einzüge</CardTitle>
|
||||
<Badge variant={batches.length > 0 ? 'default' : 'secondary'}>
|
||||
{batches.length}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{batches.length > 0
|
||||
? `${batches.length} SEPA-Einzüge mit einem Gesamtvolumen von ${formatCurrency(sepaTotal)}.`
|
||||
: 'Keine SEPA-Einzüge vorhanden.'}
|
||||
</p>
|
||||
<Link href={`/home/${account}/finance/sepa`}>
|
||||
<Button variant="outline" size="sm">
|
||||
Einzüge anzeigen
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft, Download } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createFinanceApi } from '@kit/finance/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string; batchId: string }>;
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
draft: 'secondary',
|
||||
ready: 'default',
|
||||
submitted: 'info',
|
||||
completed: 'outline',
|
||||
failed: 'destructive',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
ready: 'Bereit',
|
||||
submitted: 'Eingereicht',
|
||||
completed: 'Abgeschlossen',
|
||||
failed: 'Fehlgeschlagen',
|
||||
};
|
||||
|
||||
const ITEM_STATUS_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
pending: 'secondary',
|
||||
processed: 'default',
|
||||
failed: 'destructive',
|
||||
};
|
||||
|
||||
const ITEM_STATUS_LABEL: Record<string, string> = {
|
||||
pending: 'Ausstehend',
|
||||
processed: 'Verarbeitet',
|
||||
failed: 'Fehlgeschlagen',
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: unknown) =>
|
||||
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
|
||||
Number(amount),
|
||||
);
|
||||
|
||||
export default async function SepaBatchDetailPage({ params }: PageProps) {
|
||||
const { account, batchId } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createFinanceApi(client);
|
||||
|
||||
const [batch, items] = await Promise.all([
|
||||
api.getBatch(batchId),
|
||||
api.getBatchItems(batchId),
|
||||
]);
|
||||
|
||||
if (!batch) return <div>Einzug nicht gefunden</div>;
|
||||
|
||||
const status = String(batch.status);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="SEPA-Einzug Details">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Back link */}
|
||||
<div>
|
||||
<Link
|
||||
href={`/home/${account}/finance/sepa`}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Zurück zu SEPA-Lastschriften
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Summary Card */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>
|
||||
{String(batch.description ?? 'SEPA-Einzug')}
|
||||
</CardTitle>
|
||||
<Badge variant={STATUS_VARIANT[status] ?? 'secondary'}>
|
||||
{STATUS_LABEL[status] ?? status}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Typ
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm font-semibold">
|
||||
{batch.batch_type === 'direct_debit'
|
||||
? 'Lastschrift'
|
||||
: 'Überweisung'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Betrag
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm font-semibold">
|
||||
{batch.total_amount != null
|
||||
? formatCurrency(batch.total_amount)
|
||||
: '—'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Anzahl
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm font-semibold">
|
||||
{String(batch.item_count ?? items.length)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Ausführungsdatum
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm font-semibold">
|
||||
{batch.execution_date
|
||||
? new Date(
|
||||
String(batch.execution_date),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button disabled variant="outline">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
XML herunterladen
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Items Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Positionen ({items.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{items.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
Keine Positionen vorhanden.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">IBAN</th>
|
||||
<th className="p-3 text-right font-medium">Betrag</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item: Record<string, unknown>) => {
|
||||
const itemStatus = String(item.status ?? 'pending');
|
||||
return (
|
||||
<tr
|
||||
key={String(item.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
{String(item.debtor_name ?? '—')}
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
{String(item.debtor_iban ?? '—')}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{item.amount != null
|
||||
? formatCurrency(item.amount)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
ITEM_STATUS_VARIANT[itemStatus] ?? 'secondary'
|
||||
}
|
||||
>
|
||||
{ITEM_STATUS_LABEL[itemStatus] ?? itemStatus}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
117
apps/web/app/[locale]/home/[account]/finance/sepa/new/page.tsx
Normal file
117
apps/web/app/[locale]/home/[account]/finance/sepa/new/page.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewSepaPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neuer SEPA-Einzug">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Back link */}
|
||||
<div>
|
||||
<Link
|
||||
href={`/home/${account}/finance/sepa`}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Zurück zu SEPA-Lastschriften
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Neuer SEPA-Einzug</CardTitle>
|
||||
<CardDescription>
|
||||
Erstellen Sie einen neuen Lastschrifteinzug oder eine Überweisung.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-5">
|
||||
{/* Typ */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="batchType">Typ</Label>
|
||||
<select
|
||||
id="batchType"
|
||||
name="batchType"
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
defaultValue="direct_debit"
|
||||
>
|
||||
<option value="direct_debit">Lastschrift</option>
|
||||
<option value="credit_transfer">Überweisung</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Beschreibung */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="description">Beschreibung</Label>
|
||||
<Input
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="z.B. Mitgliedsbeiträge Q1 2026"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ausführungsdatum */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="executionDate">Ausführungsdatum</Label>
|
||||
<Input
|
||||
id="executionDate"
|
||||
name="executionDate"
|
||||
type="date"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* PAIN Format Info */}
|
||||
<div className="rounded-md bg-muted/50 p-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<strong>Hinweis:</strong> Nach dem Erstellen können Sie
|
||||
einzelne Positionen hinzufügen und anschließend die
|
||||
SEPA-XML-Datei generieren.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-between">
|
||||
<Link href={`/home/${account}/finance/sepa`}>
|
||||
<Button variant="outline">Abbrechen</Button>
|
||||
</Link>
|
||||
<Button type="submit">Einzug erstellen</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
164
apps/web/app/[locale]/home/[account]/finance/sepa/page.tsx
Normal file
164
apps/web/app/[locale]/home/[account]/finance/sepa/page.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Landmark, Plus } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createFinanceApi } from '@kit/finance/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
draft: 'secondary',
|
||||
ready: 'default',
|
||||
submitted: 'info',
|
||||
completed: 'outline',
|
||||
failed: 'destructive',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
ready: 'Bereit',
|
||||
submitted: 'Eingereicht',
|
||||
completed: 'Abgeschlossen',
|
||||
failed: 'Fehlgeschlagen',
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: unknown) =>
|
||||
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
|
||||
Number(amount),
|
||||
);
|
||||
|
||||
export default async function SepaPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createFinanceApi(client);
|
||||
const batches = await api.listBatches(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="SEPA-Lastschriften">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">SEPA-Lastschriften</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Lastschrifteinzüge verwalten
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link href={`/home/${account}/finance/sepa/new`}>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Einzug
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Table or Empty State */}
|
||||
{batches.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Landmark className="h-8 w-8" />}
|
||||
title="Keine SEPA-Einzüge"
|
||||
description="Erstellen Sie Ihren ersten SEPA-Einzug."
|
||||
actionLabel="Neuer Einzug"
|
||||
actionHref={`/home/${account}/finance/sepa/new`}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Einzüge ({batches.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-left font-medium">Typ</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
Beschreibung
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">Betrag</th>
|
||||
<th className="p-3 text-right font-medium">Anzahl</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
Ausführungsdatum
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batches.map((batch: Record<string, unknown>) => (
|
||||
<tr
|
||||
key={String(batch.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
>
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
STATUS_VARIANT[String(batch.status)] ?? 'secondary'
|
||||
}
|
||||
>
|
||||
{STATUS_LABEL[String(batch.status)] ??
|
||||
String(batch.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{batch.batch_type === 'direct_debit'
|
||||
? 'Lastschrift'
|
||||
: 'Überweisung'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/home/${account}/finance/sepa/${String(batch.id)}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{String(batch.description ?? '—')}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{batch.total_amount != null
|
||||
? formatCurrency(batch.total_amount)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{String(batch.item_count ?? 0)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{batch.execution_date
|
||||
? new Date(
|
||||
String(batch.execution_date),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { User, Mail, Phone, MapPin, CreditCard, Pencil, Ban } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string; memberId: string }>;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
active: 'Aktiv',
|
||||
inactive: 'Inaktiv',
|
||||
pending: 'Ausstehend',
|
||||
cancelled: 'Gekündigt',
|
||||
};
|
||||
|
||||
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
active: 'default',
|
||||
inactive: 'secondary',
|
||||
pending: 'outline',
|
||||
cancelled: 'destructive',
|
||||
};
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-medium">{value || '—'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function MemberDetailPage({ params }: PageProps) {
|
||||
const { account, memberId } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
|
||||
const member = await api.getMember(memberId);
|
||||
|
||||
if (!member) return <div>Mitglied nicht gefunden</div>;
|
||||
|
||||
const m = member as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={`${String(m.first_name)} ${String(m.last_name)}`}>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{String(m.first_name)} {String(m.last_name)}
|
||||
</h1>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Badge variant={STATUS_VARIANT[String(m.status)] ?? 'secondary'}>
|
||||
{STATUS_LABEL[String(m.status)] ?? String(m.status)}
|
||||
</Badge>
|
||||
{m.member_number ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Nr. {String(m.member_number)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline">
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button variant="destructive">
|
||||
<Ban className="mr-2 h-4 w-4" />
|
||||
Kündigen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Persönliche Daten */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
Persönliche Daten
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-4">
|
||||
<DetailRow label="Vorname" value={String(m.first_name ?? '')} />
|
||||
<DetailRow label="Nachname" value={String(m.last_name ?? '')} />
|
||||
<DetailRow
|
||||
label="Geburtsdatum"
|
||||
value={m.date_of_birth ? new Date(String(m.date_of_birth)).toLocaleDateString('de-DE') : ''}
|
||||
/>
|
||||
<DetailRow label="Geschlecht" value={String(m.gender ?? '')} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Kontakt */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
Kontakt
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-4">
|
||||
<DetailRow label="E-Mail" value={String(m.email ?? '')} />
|
||||
<DetailRow label="Telefon" value={String(m.phone ?? '')} />
|
||||
<DetailRow label="Mobil" value={String(m.mobile ?? '')} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Adresse */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
Adresse
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-4">
|
||||
<DetailRow label="Straße" value={`${String(m.street ?? '')} ${String(m.house_number ?? '')}`} />
|
||||
<DetailRow label="PLZ / Ort" value={`${String(m.postal_code ?? '')} ${String(m.city ?? '')}`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Mitgliedschaft */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
Mitgliedschaft
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-4">
|
||||
<DetailRow label="Mitgliedsnr." value={String(m.member_number ?? '')} />
|
||||
<DetailRow label="Status" value={STATUS_LABEL[String(m.status)] ?? String(m.status ?? '')} />
|
||||
<DetailRow
|
||||
label="Eintrittsdatum"
|
||||
value={m.entry_date ? new Date(String(m.entry_date)).toLocaleDateString('de-DE') : ''}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Austrittsdatum"
|
||||
value={m.exit_date ? new Date(String(m.exit_date)).toLocaleDateString('de-DE') : ''}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SEPA */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CreditCard className="h-4 w-4" />
|
||||
SEPA-Bankverbindung
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<DetailRow label="IBAN" value={String(m.iban ?? '')} />
|
||||
<DetailRow label="BIC" value={String(m.bic ?? '')} />
|
||||
<DetailRow label="Kontoinhaber" value={String(m.account_holder ?? '')} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { UserCheck, UserX, FileText } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'destructive'> = {
|
||||
pending: 'secondary',
|
||||
approved: 'default',
|
||||
rejected: 'destructive',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: 'Ausstehend',
|
||||
approved: 'Genehmigt',
|
||||
rejected: 'Abgelehnt',
|
||||
};
|
||||
|
||||
export default async function ApplicationsPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const applications = await api.listApplications(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Anträge">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Mitgliedsanträge</h1>
|
||||
<p className="text-muted-foreground">Eingehende Anträge prüfen und bearbeiten</p>
|
||||
</div>
|
||||
|
||||
{applications.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FileText className="h-8 w-8" />}
|
||||
title="Keine Anträge"
|
||||
description="Es liegen derzeit keine Mitgliedsanträge vor."
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Anträge ({applications.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
||||
<th className="p-3 text-left font-medium">Datum</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-right font-medium">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{applications.map((app: Record<string, unknown>) => (
|
||||
<tr key={String(app.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">
|
||||
{String(app.last_name ?? '')}, {String(app.first_name ?? '')}
|
||||
</td>
|
||||
<td className="p-3">{String(app.email ?? '—')}</td>
|
||||
<td className="p-3">
|
||||
{app.created_at
|
||||
? new Date(String(app.created_at)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={STATUS_VARIANT[String(app.status)] ?? 'secondary'}>
|
||||
{STATUS_LABEL[String(app.status)] ?? String(app.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{String(app.status) === 'pending' && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="default">
|
||||
<UserCheck className="mr-1 h-3 w-3" />
|
||||
Genehmigen
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive">
|
||||
<UserX className="mr-1 h-3 w-3" />
|
||||
Ablehnen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Euro, Plus } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function DuesPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const categories = await api.listDuesCategories(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Beitragskategorien">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Beitragskategorien</h1>
|
||||
<p className="text-muted-foreground">Beiträge und Gebühren verwalten</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neue Kategorie
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{categories.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Euro className="h-8 w-8" />}
|
||||
title="Keine Beitragskategorien"
|
||||
description="Legen Sie Ihre erste Beitragskategorie an."
|
||||
actionLabel="Neue Kategorie"
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Kategorien ({categories.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">Beschreibung</th>
|
||||
<th className="p-3 text-right font-medium">Betrag (€)</th>
|
||||
<th className="p-3 text-left font-medium">Intervall</th>
|
||||
<th className="p-3 text-center font-medium">Standard</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.map((cat: Record<string, unknown>) => (
|
||||
<tr key={String(cat.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">{String(cat.name)}</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{String(cat.description ?? '—')}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{cat.amount != null ? `${Number(cat.amount).toFixed(2)}` : '—'}
|
||||
</td>
|
||||
<td className="p-3">{String(cat.interval ?? '—')}</td>
|
||||
<td className="p-3 text-center">
|
||||
{cat.is_default ? '✓' : '✗'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
183
apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx
Normal file
183
apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { UserPlus } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewMemberPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neues Mitglied">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Neues Mitglied</h1>
|
||||
<p className="text-muted-foreground">Mitglied manuell anlegen</p>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-col gap-6">
|
||||
{/* Persönliche Daten */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Persönliche Daten</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">Vorname</Label>
|
||||
<Input id="firstName" name="firstName" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">Nachname</Label>
|
||||
<Input id="lastName" name="lastName" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dateOfBirth">Geburtsdatum</Label>
|
||||
<Input id="dateOfBirth" name="dateOfBirth" type="date" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gender">Geschlecht</Label>
|
||||
<select
|
||||
id="gender"
|
||||
name="gender"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">— Bitte wählen —</option>
|
||||
<option value="male">Männlich</option>
|
||||
<option value="female">Weiblich</option>
|
||||
<option value="diverse">Divers</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Kontakt */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kontakt</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-Mail</Label>
|
||||
<Input id="email" name="email" type="email" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Telefon</Label>
|
||||
<Input id="phone" name="phone" type="tel" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mobile">Mobil</Label>
|
||||
<Input id="mobile" name="mobile" type="tel" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Adresse */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Adresse</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2 sm:col-span-1">
|
||||
<Label htmlFor="street">Straße</Label>
|
||||
<Input id="street" name="street" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="houseNumber">Hausnummer</Label>
|
||||
<Input id="houseNumber" name="houseNumber" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postalCode">PLZ</Label>
|
||||
<Input id="postalCode" name="postalCode" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Input id="city" name="city" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Mitgliedschaft */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mitgliedschaft</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="memberNumber">Mitgliedsnr.</Label>
|
||||
<Input id="memberNumber" name="memberNumber" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="active">Aktiv</option>
|
||||
<option value="inactive">Inaktiv</option>
|
||||
<option value="pending">Ausstehend</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entryDate">Eintrittsdatum</Label>
|
||||
<Input id="entryDate" name="entryDate" type="date" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SEPA */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>SEPA-Bankverbindung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="iban">IBAN</Label>
|
||||
<Input id="iban" name="iban" placeholder="DE89 3704 0044 0532 0130 00" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bic">BIC</Label>
|
||||
<Input id="bic" name="bic" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accountHolder">Kontoinhaber</Label>
|
||||
<Input id="accountHolder" name="accountHolder" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notizen */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notizen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<textarea
|
||||
name="notes"
|
||||
rows={4}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder="Zusätzliche Anmerkungen…"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" size="lg">
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Mitglied erstellen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
66
apps/web/app/[locale]/home/[account]/members-cms/page.tsx
Normal file
66
apps/web/app/[locale]/home/[account]/members-cms/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
|
||||
interface MembersPageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function MembersPage({ params, searchParams }: MembersPageProps) {
|
||||
const { account } = await params;
|
||||
const search = await searchParams;
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
|
||||
const { data: accountData } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!accountData) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const page = Number(search.page) || 1;
|
||||
const result = await api.listMembers(accountData.id, {
|
||||
search: search.q as string,
|
||||
status: search.status as string,
|
||||
page,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Mitglieder</h1>
|
||||
<p className="text-muted-foreground">{result.total} Mitglieder</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left">Nr.</th>
|
||||
<th className="p-3 text-left">Name</th>
|
||||
<th className="p-3 text-left">E-Mail</th>
|
||||
<th className="p-3 text-left">Ort</th>
|
||||
<th className="p-3 text-left">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.data.map((member: Record<string, unknown>) => (
|
||||
<tr key={String(member.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3">{String(member.member_number ?? '—')}</td>
|
||||
<td className="p-3 font-medium">{String(member.last_name)}, {String(member.first_name)}</td>
|
||||
<td className="p-3">{String(member.email ?? '—')}</td>
|
||||
<td className="p-3">{String(member.postal_code ?? '')} {String(member.city ?? '')}</td>
|
||||
<td className="p-3">{String(member.status)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Users, UserCheck, UserMinus, Clock, BarChart3, TrendingUp } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function MemberStatisticsPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const stats = await api.getMemberStatistics(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Mitglieder-Statistiken">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Mitglieder-Statistiken</h1>
|
||||
<p className="text-muted-foreground">Übersicht über Ihre Mitglieder</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title="Gesamt"
|
||||
value={stats.total ?? 0}
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Aktiv"
|
||||
value={stats.active ?? 0}
|
||||
icon={<UserCheck className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Inaktiv"
|
||||
value={stats.inactive ?? 0}
|
||||
icon={<UserMinus className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Ausstehend"
|
||||
value={stats.pending ?? 0}
|
||||
icon={<Clock className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chart Placeholders */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
Mitgliederentwicklung
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-64 items-center justify-center rounded-md border border-dashed text-muted-foreground">
|
||||
Diagramm wird hier angezeigt
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
Eintritte / Austritte pro Monat
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-64 items-center justify-center rounded-md border border-dashed text-muted-foreground">
|
||||
Diagramm wird hier angezeigt
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
||||
import { ModuleForm } from '@kit/module-builder/components';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Pencil, Trash2, Lock, Unlock } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface RecordDetailPageProps {
|
||||
params: Promise<{ account: string; moduleId: string; recordId: string }>;
|
||||
}
|
||||
|
||||
export default async function RecordDetailPage({ params }: RecordDetailPageProps) {
|
||||
const { account, moduleId, recordId } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createModuleBuilderApi(client);
|
||||
|
||||
const [moduleWithFields, record] = await Promise.all([
|
||||
api.modules.getModuleWithFields(moduleId),
|
||||
api.records.getRecord(recordId),
|
||||
]);
|
||||
|
||||
if (!moduleWithFields || !record) return <div>Nicht gefunden</div>;
|
||||
|
||||
const fields = (moduleWithFields as unknown as {
|
||||
fields: Array<{
|
||||
name: string; display_name: string; field_type: string;
|
||||
is_required: boolean; placeholder: string | null;
|
||||
help_text: string | null; is_readonly: boolean;
|
||||
select_options: Array<{ label: string; value: string }> | null;
|
||||
section: string; sort_order: number; show_in_form: boolean; width: string;
|
||||
}>;
|
||||
}).fields;
|
||||
|
||||
const data = (record.data ?? {}) as Record<string, unknown>;
|
||||
const isLocked = record.status === 'locked';
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={`${String(moduleWithFields.display_name)} — Datensatz`}>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={isLocked ? 'destructive' : record.status === 'active' ? 'default' : 'secondary'}>
|
||||
{String(record.status)}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Erstellt: {new Date(record.created_at).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isLocked ? (
|
||||
<Button variant="outline" size="sm">
|
||||
<Unlock className="mr-2 h-4 w-4" />
|
||||
Entsperren
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm">
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Sperren
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="destructive" size="sm">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Pencil className="h-4 w-4" />
|
||||
Datensatz bearbeiten
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ModuleForm
|
||||
fields={fields as Parameters<typeof ModuleForm>[0]['fields']}
|
||||
initialData={data}
|
||||
onSubmit={async () => {}}
|
||||
isLoading={false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Upload, ArrowRight, CheckCircle } from 'lucide-react';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface ImportPageProps {
|
||||
params: Promise<{ account: string; moduleId: string }>;
|
||||
}
|
||||
|
||||
export default async function ImportPage({ params }: ImportPageProps) {
|
||||
const { account, moduleId } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createModuleBuilderApi(client);
|
||||
|
||||
const moduleWithFields = await api.modules.getModuleWithFields(moduleId);
|
||||
if (!moduleWithFields) return <div>Modul nicht gefunden</div>;
|
||||
|
||||
const fields = (moduleWithFields as unknown as { fields: Array<{ name: string; display_name: string }> }).fields ?? [];
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={`${String(moduleWithFields.display_name)} — Import`}>
|
||||
<div className="space-y-6">
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{['Datei hochladen', 'Spalten zuordnen', 'Vorschau', 'Importieren'].map((step, i) => (
|
||||
<div key={step} className="flex items-center gap-2">
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold ${
|
||||
i === 0 ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{i + 1}
|
||||
</div>
|
||||
<span className={`text-sm ${i === 0 ? 'font-semibold' : 'text-muted-foreground'}`}>{step}</span>
|
||||
{i < 3 && <ArrowRight className="h-4 w-4 text-muted-foreground" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Upload Step */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4" />
|
||||
Datei hochladen
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-12 text-center">
|
||||
<Upload className="mb-4 h-10 w-10 text-muted-foreground" />
|
||||
<p className="text-lg font-semibold">CSV oder Excel-Datei hierher ziehen</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">oder klicken zum Auswählen</p>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
className="mt-4 block w-full max-w-xs text-sm text-muted-foreground file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground hover:file:bg-primary/90"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold mb-2">Verfügbare Zielfelder:</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{fields.map((field) => (
|
||||
<span key={field.name} className="rounded-md bg-muted px-2 py-1 text-xs">
|
||||
{field.display_name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button disabled>
|
||||
Weiter <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
||||
import { ModuleForm } from '@kit/module-builder/components';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface NewRecordPageProps {
|
||||
params: Promise<{ account: string; moduleId: string }>;
|
||||
}
|
||||
|
||||
export default async function NewRecordPage({ params }: NewRecordPageProps) {
|
||||
const { account, moduleId } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createModuleBuilderApi(client);
|
||||
|
||||
const moduleWithFields = await api.modules.getModuleWithFields(moduleId);
|
||||
if (!moduleWithFields) return <div>Modul nicht gefunden</div>;
|
||||
|
||||
const fields = (moduleWithFields as unknown as {
|
||||
fields: Array<{
|
||||
name: string; display_name: string; field_type: string;
|
||||
is_required: boolean; placeholder: string | null;
|
||||
help_text: string | null; is_readonly: boolean;
|
||||
select_options: Array<{ label: string; value: string }> | null;
|
||||
section: string; sort_order: number; show_in_form: boolean; width: string;
|
||||
}>;
|
||||
}).fields;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={`${String(moduleWithFields.display_name)} — Neuer Datensatz`}>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<ModuleForm
|
||||
fields={fields as Parameters<typeof ModuleForm>[0]['fields']}
|
||||
onSubmit={async () => {}}
|
||||
isLoading={false}
|
||||
/>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
||||
|
||||
interface ModuleDetailPageProps {
|
||||
params: Promise<{ account: string; moduleId: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function ModuleDetailPage({ params, searchParams }: ModuleDetailPageProps) {
|
||||
const { account, moduleId } = await params;
|
||||
const search = await searchParams;
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createModuleBuilderApi(client);
|
||||
|
||||
const moduleWithFields = await api.modules.getModuleWithFields(moduleId);
|
||||
|
||||
if (!moduleWithFields) {
|
||||
return <div>Modul nicht gefunden</div>;
|
||||
}
|
||||
|
||||
const page = Number(search.page) || 1;
|
||||
const pageSize = Number(search.pageSize) || moduleWithFields.default_page_size || 25;
|
||||
|
||||
const result = await api.query.query({
|
||||
moduleId,
|
||||
page,
|
||||
pageSize,
|
||||
sortField: (search.sort as string) ?? moduleWithFields.default_sort_field ?? undefined,
|
||||
sortDirection: (search.dir as 'asc' | 'desc') ?? (moduleWithFields.default_sort_direction as 'asc' | 'desc') ?? 'asc',
|
||||
search: (search.q as string) ?? undefined,
|
||||
filters: [],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{moduleWithFields.display_name}</h1>
|
||||
{moduleWithFields.description && (
|
||||
<p className="text-muted-foreground">{moduleWithFields.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{result.pagination.total} Datensätze — Seite {result.pagination.page} von {result.pagination.totalPages}
|
||||
</div>
|
||||
|
||||
{/* Phase 3 will replace this with module-table component */}
|
||||
<div className="rounded-lg border">
|
||||
<pre className="p-4 text-xs overflow-auto max-h-96">
|
||||
{JSON.stringify(result.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { Settings2, List, Shield } from 'lucide-react';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface ModuleSettingsPageProps {
|
||||
params: Promise<{ account: string; moduleId: string }>;
|
||||
}
|
||||
|
||||
export default async function ModuleSettingsPage({ params }: ModuleSettingsPageProps) {
|
||||
const { account, moduleId } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createModuleBuilderApi(client);
|
||||
|
||||
const moduleWithFields = await api.modules.getModuleWithFields(moduleId);
|
||||
if (!moduleWithFields) return <div>Modul nicht gefunden</div>;
|
||||
|
||||
const mod = moduleWithFields;
|
||||
const fields = (mod as unknown as { fields: Array<Record<string, unknown>> }).fields ?? [];
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={`${String(mod.display_name)} — Einstellungen`}>
|
||||
<div className="space-y-6">
|
||||
{/* General Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
Allgemein
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Anzeigename</Label>
|
||||
<Input defaultValue={String(mod.display_name)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Systemname</Label>
|
||||
<Input defaultValue={String(mod.name)} readOnly className="bg-muted" />
|
||||
</div>
|
||||
<div className="col-span-full space-y-2">
|
||||
<Label>Beschreibung</Label>
|
||||
<Input defaultValue={String(mod.description ?? '')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Symbol</Label>
|
||||
<Input defaultValue={String(mod.icon ?? 'table')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Seitengröße</Label>
|
||||
<Input type="number" defaultValue={String(mod.default_page_size ?? 25)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ key: 'enable_search', label: 'Suche' },
|
||||
{ key: 'enable_filter', label: 'Filter' },
|
||||
{ key: 'enable_export', label: 'Export' },
|
||||
{ key: 'enable_import', label: 'Import' },
|
||||
{ key: 'enable_print', label: 'Drucken' },
|
||||
{ key: 'enable_copy', label: 'Kopieren' },
|
||||
{ key: 'enable_history', label: 'Verlauf' },
|
||||
{ key: 'enable_soft_delete', label: 'Papierkorb' },
|
||||
{ key: 'enable_lock', label: 'Sperren' },
|
||||
].map(({ key, label }) => (
|
||||
<Badge
|
||||
key={key}
|
||||
variant={(mod as Record<string, unknown>)[key] ? 'default' : 'secondary'}
|
||||
>
|
||||
{(mod as Record<string, unknown>)[key] ? '✓' : '✗'} {label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<Button>Einstellungen speichern</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Field Definitions */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<List className="h-4 w-4" />
|
||||
Felder ({fields.length})
|
||||
</CardTitle>
|
||||
<Button size="sm">+ Feld hinzufügen</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left">Name</th>
|
||||
<th className="p-3 text-left">Anzeigename</th>
|
||||
<th className="p-3 text-left">Typ</th>
|
||||
<th className="p-3 text-left">Pflicht</th>
|
||||
<th className="p-3 text-left">Tabelle</th>
|
||||
<th className="p-3 text-left">Formular</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-8 text-center text-muted-foreground">
|
||||
Noch keine Felder definiert
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
fields.map((field) => (
|
||||
<tr key={String(field.id)} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 font-mono text-xs">{String(field.name)}</td>
|
||||
<td className="p-3">{String(field.display_name)}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="secondary">{String(field.field_type)}</Badge>
|
||||
</td>
|
||||
<td className="p-3">{field.is_required ? '✓' : '—'}</td>
|
||||
<td className="p-3">{field.show_in_table ? '✓' : '—'}</td>
|
||||
<td className="p-3">{field.show_in_form ? '✓' : '—'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Permissions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Berechtigungen
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Modulspezifische Berechtigungen pro Rolle können hier konfiguriert werden.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
66
apps/web/app/[locale]/home/[account]/modules/page.tsx
Normal file
66
apps/web/app/[locale]/home/[account]/modules/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
||||
|
||||
interface ModulesPageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function ModulesPage({ params }: ModulesPageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createModuleBuilderApi(client);
|
||||
|
||||
// Get the account ID from slug
|
||||
const { data: accountData } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!accountData) {
|
||||
return <div>Account not found</div>;
|
||||
}
|
||||
|
||||
const modules = await api.modules.listModules(accountData.id);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Module</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Verwalten Sie Ihre Datenmodule
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modules.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Noch keine Module vorhanden. Erstellen Sie Ihr erstes Modul.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{modules.map((module: Record<string, unknown>) => (
|
||||
<div
|
||||
key={module.id as string}
|
||||
className="rounded-lg border p-4 hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<h3 className="font-semibold">{String(module.display_name)}</h3>
|
||||
{module.description ? (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{String(module.description)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Status: {String(module.status)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft, Send, Users } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createNewsletterApi } from '@kit/newsletter/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string; campaignId: string }>;
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
draft: 'secondary',
|
||||
scheduled: 'default',
|
||||
sending: 'info',
|
||||
sent: 'outline',
|
||||
failed: 'destructive',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
scheduled: 'Geplant',
|
||||
sending: 'Wird gesendet',
|
||||
sent: 'Gesendet',
|
||||
failed: 'Fehlgeschlagen',
|
||||
};
|
||||
|
||||
const RECIPIENT_STATUS_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
pending: 'secondary',
|
||||
sent: 'default',
|
||||
failed: 'destructive',
|
||||
bounced: 'destructive',
|
||||
};
|
||||
|
||||
const RECIPIENT_STATUS_LABEL: Record<string, string> = {
|
||||
pending: 'Ausstehend',
|
||||
sent: 'Gesendet',
|
||||
failed: 'Fehlgeschlagen',
|
||||
bounced: 'Zurückgewiesen',
|
||||
};
|
||||
|
||||
export default async function NewsletterDetailPage({ params }: PageProps) {
|
||||
const { account, campaignId } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createNewsletterApi(client);
|
||||
|
||||
const [newsletter, recipients] = await Promise.all([
|
||||
api.getNewsletter(campaignId),
|
||||
api.getRecipients(campaignId),
|
||||
]);
|
||||
|
||||
if (!newsletter) return <div>Newsletter nicht gefunden</div>;
|
||||
|
||||
const status = String(newsletter.status);
|
||||
const sentCount = recipients.filter(
|
||||
(r: Record<string, unknown>) => r.status === 'sent',
|
||||
).length;
|
||||
const failedCount = recipients.filter(
|
||||
(r: Record<string, unknown>) =>
|
||||
r.status === 'failed' || r.status === 'bounced',
|
||||
).length;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Newsletter Details">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Back link */}
|
||||
<div>
|
||||
<Link
|
||||
href={`/home/${account}/newsletter`}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Zurück zu Newsletter
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Summary Card */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>
|
||||
{String(newsletter.subject ?? '(Kein Betreff)')}
|
||||
</CardTitle>
|
||||
<Badge variant={STATUS_VARIANT[status] ?? 'secondary'}>
|
||||
{STATUS_LABEL[status] ?? status}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<StatsCard
|
||||
title="Empfänger"
|
||||
value={recipients.length}
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Gesendet"
|
||||
value={sentCount}
|
||||
icon={<Send className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Fehlgeschlagen"
|
||||
value={failedCount}
|
||||
icon={<Send className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{status === 'draft' && (
|
||||
<div className="mt-6">
|
||||
<Button>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Newsletter versenden
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recipients Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Empfänger ({recipients.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recipients.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
Keine Empfänger hinzugefügt. Fügen Sie Empfänger aus Ihrer
|
||||
Mitgliederliste hinzu.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recipients.map((recipient: Record<string, unknown>) => {
|
||||
const rStatus = String(recipient.status ?? 'pending');
|
||||
return (
|
||||
<tr
|
||||
key={String(recipient.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
{String(recipient.name ?? '—')}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{String(recipient.email ?? '—')}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
RECIPIENT_STATUS_VARIANT[rStatus] ?? 'secondary'
|
||||
}
|
||||
>
|
||||
{RECIPIENT_STATUS_LABEL[rStatus] ?? rStatus}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
125
apps/web/app/[locale]/home/[account]/newsletter/new/page.tsx
Normal file
125
apps/web/app/[locale]/home/[account]/newsletter/new/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { Textarea } from '@kit/ui/textarea';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewNewsletterPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Neuer Newsletter">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Back link */}
|
||||
<div>
|
||||
<Link
|
||||
href={`/home/${account}/newsletter`}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Zurück zu Newsletter
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Neuer Newsletter</CardTitle>
|
||||
<CardDescription>
|
||||
Erstellen Sie eine neue Newsletter-Kampagne.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-5">
|
||||
{/* Betreff */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="subject">Betreff</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
name="subject"
|
||||
placeholder="z.B. Monatliche Vereinsnachrichten März 2026"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Body HTML */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bodyHtml">Inhalt (HTML)</Label>
|
||||
<Textarea
|
||||
id="bodyHtml"
|
||||
name="bodyHtml"
|
||||
placeholder="<h1>Hallo {{first_name}},</h1> <p>Neuigkeiten aus dem Verein...</p>"
|
||||
rows={10}
|
||||
className="font-mono text-sm"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Verwenden Sie {'{{first_name}}'}, {'{{name}}'} und{' '}
|
||||
{'{{email}}'} als Platzhalter für die Personalisierung.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Empfänger Info */}
|
||||
<div className="rounded-md bg-muted/50 p-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<strong>Empfänger-Auswahl:</strong> Nach dem Erstellen können
|
||||
Sie die Empfänger aus Ihrer Mitgliederliste auswählen. Es
|
||||
werden nur Mitglieder mit hinterlegter E-Mail-Adresse
|
||||
berücksichtigt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Vorlage Auswahl */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="templateId">
|
||||
Vorlage (optional)
|
||||
</Label>
|
||||
<select
|
||||
id="templateId"
|
||||
name="templateId"
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="">Keine Vorlage</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-between">
|
||||
<Link href={`/home/${account}/newsletter`}>
|
||||
<Button variant="outline">Abbrechen</Button>
|
||||
</Link>
|
||||
<Button type="submit">Newsletter erstellen</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
178
apps/web/app/[locale]/home/[account]/newsletter/page.tsx
Normal file
178
apps/web/app/[locale]/home/[account]/newsletter/page.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Mail, Plus, Send, Users } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createNewsletterApi } from '@kit/newsletter/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
const STATUS_BADGE_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||
> = {
|
||||
draft: 'secondary',
|
||||
scheduled: 'default',
|
||||
sending: 'info',
|
||||
sent: 'outline',
|
||||
failed: 'destructive',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
scheduled: 'Geplant',
|
||||
sending: 'Wird gesendet',
|
||||
sent: 'Gesendet',
|
||||
failed: 'Fehlgeschlagen',
|
||||
};
|
||||
|
||||
export default async function NewsletterPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createNewsletterApi(client);
|
||||
const newsletters = await api.listNewsletters(acct.id);
|
||||
|
||||
const sentCount = newsletters.filter(
|
||||
(n: Record<string, unknown>) => n.status === 'sent',
|
||||
).length;
|
||||
|
||||
const totalRecipients = newsletters.reduce(
|
||||
(sum: number, n: Record<string, unknown>) =>
|
||||
sum + (Number(n.total_recipients) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Newsletter">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Newsletter</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Newsletter erstellen und versenden
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link href={`/home/${account}/newsletter/new`}>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Newsletter
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<StatsCard
|
||||
title="Newsletter"
|
||||
value={newsletters.length}
|
||||
icon={<Mail className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Gesendet"
|
||||
value={sentCount}
|
||||
icon={<Send className="h-5 w-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Empfänger gesamt"
|
||||
value={totalRecipients}
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table or Empty State */}
|
||||
{newsletters.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Mail className="h-8 w-8" />}
|
||||
title="Keine Newsletter vorhanden"
|
||||
description="Erstellen Sie Ihren ersten Newsletter, um loszulegen."
|
||||
actionLabel="Neuer Newsletter"
|
||||
actionHref={`/home/${account}/newsletter/new`}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Newsletter ({newsletters.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Betreff</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-right font-medium">Empfänger</th>
|
||||
<th className="p-3 text-left font-medium">Erstellt</th>
|
||||
<th className="p-3 text-left font-medium">Gesendet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{newsletters.map((nl: Record<string, unknown>) => (
|
||||
<tr
|
||||
key={String(nl.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
<Link
|
||||
href={`/home/${account}/newsletter/${String(nl.id)}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{String(nl.subject ?? '(Kein Betreff)')}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
STATUS_BADGE_VARIANT[String(nl.status)] ?? 'secondary'
|
||||
}
|
||||
>
|
||||
{STATUS_LABEL[String(nl.status)] ?? String(nl.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{nl.total_recipients != null
|
||||
? String(nl.total_recipients)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{nl.created_at
|
||||
? new Date(String(nl.created_at)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{nl.sent_at
|
||||
? new Date(String(nl.sent_at)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { FileText, Plus } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { createNewsletterApi } from '@kit/newsletter/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function NewsletterTemplatesPage({ params }: PageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
const api = createNewsletterApi(client);
|
||||
const templates = await api.listTemplates(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Newsletter-Vorlagen">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Newsletter-Vorlagen</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Wiederverwendbare Vorlagen für Newsletter
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neue Vorlage
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table or Empty State */}
|
||||
{templates.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FileText className="h-8 w-8" />}
|
||||
title="Keine Vorlagen vorhanden"
|
||||
description="Erstellen Sie Ihre erste Newsletter-Vorlage, um sie in Kampagnen wiederzuverwenden."
|
||||
actionLabel="Neue Vorlage"
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alle Vorlagen ({templates.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="p-3 text-left font-medium">Name</th>
|
||||
<th className="p-3 text-left font-medium">Betreff</th>
|
||||
<th className="p-3 text-left font-medium">Variablen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templates.map((template: Record<string, unknown>) => {
|
||||
const variables = Array.isArray(template.variables)
|
||||
? (template.variables as string[])
|
||||
: [];
|
||||
return (
|
||||
<tr
|
||||
key={String(template.id)}
|
||||
className="border-b hover:bg-muted/30"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
{String(template.name ?? '—')}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{String(template.subject ?? '—')}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{variables.length > 0
|
||||
? variables.map((v) => (
|
||||
<Badge
|
||||
key={v}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
{`{{${v}}}`}
|
||||
</Badge>
|
||||
))
|
||||
: <span className="text-muted-foreground">—</span>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +1,381 @@
|
||||
import { use } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import {
|
||||
ArrowRight,
|
||||
FileText,
|
||||
GraduationCap,
|
||||
Mail,
|
||||
Plus,
|
||||
UserCheck,
|
||||
UserPlus,
|
||||
CalendarDays,
|
||||
Activity,
|
||||
BedDouble,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||
import { PageBody } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
|
||||
import { DashboardDemo } from './_components/dashboard-demo';
|
||||
import { TeamAccountLayoutPageHeader } from './_components/team-account-layout-page-header';
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
import { createFinanceApi } from '@kit/finance/api';
|
||||
import { createNewsletterApi } from '@kit/newsletter/api';
|
||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||
import { createEventManagementApi } from '@kit/event-management/api';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { StatsCard } from '~/components/stats-card';
|
||||
|
||||
interface TeamAccountHomePageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const t = await getTranslations('teams');
|
||||
const title = t('home.pageTitle');
|
||||
export default async function TeamAccountHomePage({
|
||||
params,
|
||||
}: TeamAccountHomePageProps) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
return {
|
||||
title,
|
||||
};
|
||||
};
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id, name')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
function TeamAccountHomePage({ params }: TeamAccountHomePageProps) {
|
||||
const account = use(params).account;
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
|
||||
// Load all stats in parallel with allSettled for resilience
|
||||
const [
|
||||
memberStatsResult,
|
||||
courseStatsResult,
|
||||
invoicesResult,
|
||||
newslettersResult,
|
||||
bookingsResult,
|
||||
eventsResult,
|
||||
] = await Promise.allSettled([
|
||||
createMemberManagementApi(client).getMemberStatistics(acct.id),
|
||||
createCourseManagementApi(client).getStatistics(acct.id),
|
||||
createFinanceApi(client).listInvoices(acct.id, { status: 'draft' }),
|
||||
createNewsletterApi(client).listNewsletters(acct.id),
|
||||
createBookingManagementApi(client).listBookings(acct.id, { page: 1 }),
|
||||
createEventManagementApi(client).listEvents(acct.id, { page: 1 }),
|
||||
]);
|
||||
|
||||
const memberStats =
|
||||
memberStatsResult.status === 'fulfilled'
|
||||
? memberStatsResult.value
|
||||
: { total: 0, active: 0, inactive: 0, pending: 0, resigned: 0 };
|
||||
|
||||
const courseStats =
|
||||
courseStatsResult.status === 'fulfilled'
|
||||
? courseStatsResult.value
|
||||
: { totalCourses: 0, openCourses: 0, completedCourses: 0, totalParticipants: 0 };
|
||||
|
||||
const openInvoices =
|
||||
invoicesResult.status === 'fulfilled' ? invoicesResult.value : [];
|
||||
|
||||
const newsletters =
|
||||
newslettersResult.status === 'fulfilled' ? newslettersResult.value : [];
|
||||
|
||||
const bookings =
|
||||
bookingsResult.status === 'fulfilled'
|
||||
? bookingsResult.value
|
||||
: { data: [], total: 0 };
|
||||
|
||||
const events =
|
||||
eventsResult.status === 'fulfilled'
|
||||
? eventsResult.value
|
||||
: { data: [], total: 0 };
|
||||
|
||||
const accountName = acct.name ? String(acct.name) : 'Dashboard';
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<TeamAccountLayoutPageHeader
|
||||
account={account}
|
||||
title={<Trans i18nKey={'common.routes.dashboard'} />}
|
||||
description={<AppBreadcrumbs />}
|
||||
/>
|
||||
<CmsPageShell account={account} title={accountName}>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title="Mitglieder"
|
||||
value={memberStats.active}
|
||||
icon={<UserCheck className="h-5 w-5" />}
|
||||
description={`${memberStats.total} gesamt, ${memberStats.pending} ausstehend`}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Kurse"
|
||||
value={courseStats.openCourses}
|
||||
icon={<GraduationCap className="h-5 w-5" />}
|
||||
description={`${courseStats.totalCourses} gesamt, ${courseStats.totalParticipants} Teilnehmer`}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Offene Rechnungen"
|
||||
value={openInvoices.length}
|
||||
icon={<FileText className="h-5 w-5" />}
|
||||
description="Entwürfe zum Versenden"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Newsletter"
|
||||
value={newsletters.length}
|
||||
icon={<Mail className="h-5 w-5" />}
|
||||
description="Erstellt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DashboardDemo />
|
||||
</PageBody>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Letzte Aktivität */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Letzte Aktivität
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Aktuelle Buchungen und Veranstaltungen
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Recent bookings */}
|
||||
{bookings.data.slice(0, 3).map((booking: Record<string, unknown>) => (
|
||||
<div
|
||||
key={String(booking.id)}
|
||||
className="flex items-center justify-between rounded-md border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-full bg-blue-500/10 p-2 text-blue-600">
|
||||
<BedDouble className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
href={`/home/${account}/bookings/${String(booking.id)}`}
|
||||
className="text-sm font-medium hover:underline"
|
||||
>
|
||||
Buchung #{String(booking.id).slice(0, 8)}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{booking.check_in
|
||||
? new Date(
|
||||
String(booking.check_in),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}{' '}
|
||||
–{' '}
|
||||
{booking.check_out
|
||||
? new Date(
|
||||
String(booking.check_out),
|
||||
).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{String(booking.status ?? '—')}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Recent events */}
|
||||
{events.data.slice(0, 3).map((event: Record<string, unknown>) => (
|
||||
<div
|
||||
key={String(event.id)}
|
||||
className="flex items-center justify-between rounded-md border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-full bg-amber-500/10 p-2 text-amber-600">
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
href={`/home/${account}/events/${String(event.id)}`}
|
||||
className="text-sm font-medium hover:underline"
|
||||
>
|
||||
{String(event.name)}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{event.event_date
|
||||
? new Date(
|
||||
String(event.event_date),
|
||||
).toLocaleDateString('de-DE')
|
||||
: 'Kein Datum'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{String(event.status ?? '—')}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{bookings.data.length === 0 && events.data.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Activity className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Noch keine Aktivitäten vorhanden
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Schnellaktionen */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Schnellaktionen</CardTitle>
|
||||
<CardDescription>Häufig verwendete Aktionen</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<Link href={`/home/${account}/members-cms/new`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Neues Mitglied
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={`/home/${account}/courses/new`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<GraduationCap className="h-4 w-4" />
|
||||
Neuer Kurs
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={`/home/${account}/newsletter/new`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
Newsletter erstellen
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={`/home/${account}/bookings/new`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<BedDouble className="h-4 w-4" />
|
||||
Neue Buchung
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={`/home/${account}/events/new`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Neue Veranstaltung
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Module Overview Row */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Buchungen
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{bookings.total}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{bookings.data.filter(
|
||||
(b: Record<string, unknown>) =>
|
||||
b.status === 'confirmed' || b.status === 'checked_in',
|
||||
).length}{' '}
|
||||
aktiv
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/home/${account}/bookings`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Veranstaltungen
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{events.total}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{events.data.filter(
|
||||
(e: Record<string, unknown>) =>
|
||||
e.status === 'published' ||
|
||||
e.status === 'registration_open',
|
||||
).length}{' '}
|
||||
aktiv
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/home/${account}/events`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Kurse abgeschlossen
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{courseStats.completedCourses}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
von {courseStats.totalCourses} insgesamt
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/home/${account}/courses`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamAccountHomePage;
|
||||
|
||||
Reference in New Issue
Block a user