Add account hierarchy framework with migrations, RLS policies, and UI components

This commit is contained in:
T. Zehetbauer
2026-03-31 22:18:04 +02:00
parent 7e7da0b465
commit 59546ad6d2
262 changed files with 11671 additions and 3927 deletions

View File

@@ -51,14 +51,16 @@ import {
import { Trans } from '@kit/ui/trans';
import { MultiFactorAuthSetupDialog } from './multi-factor-auth-setup-dialog';
import { PasskeySetupDialog } from './passkey-setup-dialog';
export function MultiFactorAuthFactorsList(props: { userId: string }) {
return (
<div className={'flex flex-col space-y-4'}>
<FactorsTableContainer userId={props.userId} />
<div>
<div className={'flex flex-wrap gap-2'}>
<MultiFactorAuthSetupDialog userId={props.userId} />
<PasskeySetupDialog userId={props.userId} />
</div>
</div>
);

View File

@@ -0,0 +1,259 @@
'use client';
import { useCallback, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Fingerprint, TriangleAlert } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { useFactorsMutationKey } from '@kit/supabase/hooks/use-user-factors-mutation-key';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans';
import { refreshAuthSession } from '../../../server/personal-accounts-server-actions';
export function PasskeySetupDialog(props: { userId: string }) {
const t = useTranslations();
const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog();
const onEnrollSuccess = useCallback(() => {
setIsPending(false);
setOpen(false);
return toast.success(t(`account.passkeySetupSuccess` as never));
}, [t, setIsPending, setOpen]);
return (
<Dialog {...dialogProps}>
<DialogTrigger
render={
<Button variant={'outline'}>
<Fingerprint className={'h-4'} />
<Trans i18nKey={'account.setupPasskeyButtonLabel'} />
</Button>
}
/>
<DialogContent showCloseButton={!isPending}>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'account.setupPasskeyButtonLabel'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'account.passkeyDescription'} />
</DialogDescription>
</DialogHeader>
<div>
<PasskeySetupForm
userId={props.userId}
isPending={isPending}
setIsPending={setIsPending}
onCancel={() => setOpen(false)}
onEnrolled={onEnrollSuccess}
/>
</div>
</DialogContent>
</Dialog>
);
}
function PasskeySetupForm({
onEnrolled,
onCancel,
userId,
isPending,
setIsPending,
}: {
userId: string;
onCancel: () => void;
onEnrolled: () => void;
isPending: boolean;
setIsPending: (pending: boolean) => void;
}) {
const registerPasskeyMutation = useRegisterPasskey(userId);
const [error, setError] = useState('');
const form = useForm({
resolver: zodResolver(
z.object({
name: z.string().min(1),
}),
),
defaultValues: {
name: '',
},
});
const onSubmit = useCallback(
async ({ name }: { name: string }) => {
setIsPending(true);
setError('');
try {
await registerPasskeyMutation.mutateAsync(name);
await refreshAuthSession();
onEnrolled();
} catch (err) {
const message = (err as Error).message || 'Unknown error';
setIsPending(false);
setError(message);
}
},
[onEnrolled, registerPasskeyMutation, setIsPending],
);
if (error) {
return (
<div className={'flex w-full flex-col space-y-4'}>
<Alert variant={'destructive'}>
<TriangleAlert className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account.passkeySetupErrorHeading'} />
</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
<div className={'flex justify-end space-x-2'}>
<Button variant={'outline'} onClick={onCancel}>
<Trans i18nKey={'common.cancel'} />
</Button>
<Button onClick={() => setError('')}>
<Trans i18nKey={'common.retry'} />
</Button>
</div>
</div>
);
}
return (
<Form {...form}>
<form className={'w-full'} onSubmit={form.handleSubmit(onSubmit)}>
<div className={'flex flex-col space-y-4'}>
<div
className={
'flex flex-col items-center space-y-2 rounded-lg border p-4'
}
>
<Fingerprint className={'text-muted-foreground h-12 w-12'} />
<p className={'text-muted-foreground text-center text-sm'}>
<Trans i18nKey={'account.passkeySetupInstructions'} />
</p>
</div>
<FormField
name={'name'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'account.passkeyNameLabel'} />
</FormLabel>
<FormControl>
<Input
autoComplete={'off'}
placeholder={'z.B. MacBook Touch ID'}
required
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'account.passkeyNameHint'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<div className={'flex justify-end space-x-2'}>
<Button
type={'button'}
variant={'ghost'}
disabled={isPending}
onClick={onCancel}
>
<Trans i18nKey={'common.cancel'} />
</Button>
<Button
type={'submit'}
disabled={isPending || !form.formState.isValid}
>
<If condition={isPending}>
<Trans i18nKey={'account.registeringPasskey'} />
</If>
<If condition={!isPending}>
<Fingerprint className={'h-4'} />
<Trans i18nKey={'account.registerPasskey'} />
</If>
</Button>
</div>
</div>
</form>
</Form>
);
}
function useRegisterPasskey(userId: string) {
const client = useSupabase();
const queryClient = useQueryClient();
const mutationKey = useFactorsMutationKey(userId);
const mutationFn = async (friendlyName: string) => {
const { data, error } = await client.auth.mfa.webauthn.register({
friendlyName,
});
if (error) {
throw error;
}
return data;
};
return useMutation({
mutationFn,
mutationKey,
onSuccess() {
return queryClient.refetchQueries({
queryKey: mutationKey,
});
},
});
}

View File

@@ -11,6 +11,7 @@ import { EllipsisVertical } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { formatDateTime } from '@kit/shared/dates';
import { Tables } from '@kit/supabase/database';
import { Button } from '@kit/ui/button';
import {
@@ -208,31 +209,14 @@ function getColumns(): ColumnDef<Account>[] {
id: 'created_at',
header: 'Created At',
cell: ({ row }) => {
return new Date(row.original.created_at!).toLocaleDateString(
undefined,
{
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
},
);
return formatDateTime(row.original.created_at);
},
},
{
id: 'updated_at',
header: 'Updated At',
cell: ({ row }) => {
return row.original.updated_at
? new Date(row.original.updated_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: '-';
return formatDateTime(row.original.updated_at);
},
},
{

View File

@@ -55,7 +55,7 @@ const TENANT_MAPPING: Record<number, { type: string; name: string }> = {
*/
async function createMysqlConnection(config: MysqlConfig) {
// Dynamic import — mysql2 must be installed separately: pnpm add mysql2
const mysql = await import('mysql2/promise' as string) as any;
const mysql = (await import('mysql2/promise' as string)) as any;
return mysql.createConnection({
host: config.host,
port: config.port,
@@ -75,7 +75,11 @@ export async function runMigration(
): Promise<MigrationProgress> {
const db = supabase as any;
const mysql = await createMysqlConnection(mysqlConfig);
const progress: MigrationProgress = { steps: [], totalMigrated: 0, totalErrors: 0 };
const progress: MigrationProgress = {
steps: [],
totalMigrated: 0,
totalErrors: 0,
};
try {
// Step 1: Migrate users
@@ -99,8 +103,14 @@ export async function runMigration(
progress.steps.push(courseResult);
// Calculate totals
progress.totalMigrated = progress.steps.reduce((sum, s) => sum + s.count, 0);
progress.totalErrors = progress.steps.reduce((sum, s) => sum + s.errors.length, 0);
progress.totalMigrated = progress.steps.reduce(
(sum, s) => sum + s.count,
0,
);
progress.totalErrors = progress.steps.reduce(
(sum, s) => sum + s.errors.length,
0,
);
} finally {
await mysql.end();
}
@@ -113,10 +123,17 @@ async function migrateUsers(
supabase: any,
onProgress?: (step: string, count: number) => void,
): Promise<MigrationResult> {
const result: MigrationResult = { step: 'users', success: true, count: 0, errors: [] };
const result: MigrationResult = {
step: 'users',
success: true,
count: 0,
errors: [],
};
try {
const [rows] = await mysql.execute('SELECT * FROM cms_user WHERE active = 1');
const [rows] = await mysql.execute(
'SELECT * FROM cms_user WHERE active = 1',
);
onProgress?.('Migrating users', (rows as any[]).length);
for (const row of rows as any[]) {
@@ -125,12 +142,16 @@ async function migrateUsers(
// This creates a record for mapping; actual auth user creation uses supabase.auth.admin
result.count++;
} catch (err) {
result.errors.push(`User ${row.login}: ${err instanceof Error ? err.message : 'Unknown error'}`);
result.errors.push(
`User ${row.login}: ${err instanceof Error ? err.message : 'Unknown error'}`,
);
}
}
} catch (err) {
result.success = false;
result.errors.push(`Failed to read users: ${err instanceof Error ? err.message : 'Unknown error'}`);
result.errors.push(
`Failed to read users: ${err instanceof Error ? err.message : 'Unknown error'}`,
);
}
return result;
@@ -141,7 +162,12 @@ async function migrateAccounts(
supabase: any,
onProgress?: (step: string, count: number) => void,
): Promise<MigrationResult> {
const result: MigrationResult = { step: 'accounts', success: true, count: 0, errors: [] };
const result: MigrationResult = {
step: 'accounts',
success: true,
count: 0,
errors: [],
};
onProgress?.('Creating team accounts', Object.keys(TENANT_MAPPING).length);
@@ -150,7 +176,9 @@ async function migrateAccounts(
// Create account_settings entry for each tenant
result.count++;
} catch (err) {
result.errors.push(`Tenant ${profileId}: ${err instanceof Error ? err.message : 'Unknown error'}`);
result.errors.push(
`Tenant ${profileId}: ${err instanceof Error ? err.message : 'Unknown error'}`,
);
}
}
@@ -162,10 +190,17 @@ async function migrateModules(
supabase: any,
onProgress?: (step: string, count: number) => void,
): Promise<MigrationResult> {
const result: MigrationResult = { step: 'modules', success: true, count: 0, errors: [] };
const result: MigrationResult = {
step: 'modules',
success: true,
count: 0,
errors: [],
};
try {
const [modules] = await mysql.execute('SELECT * FROM m_module ORDER BY sort_order');
const [modules] = await mysql.execute(
'SELECT * FROM m_module ORDER BY sort_order',
);
onProgress?.('Migrating modules', (modules as any[]).length);
for (const mod of modules as any[]) {
@@ -178,12 +213,16 @@ async function migrateModules(
);
result.count += 1 + (fields as any[]).length;
} catch (err) {
result.errors.push(`Module ${mod.name}: ${err instanceof Error ? err.message : 'Unknown error'}`);
result.errors.push(
`Module ${mod.name}: ${err instanceof Error ? err.message : 'Unknown error'}`,
);
}
}
} catch (err) {
result.success = false;
result.errors.push(`Failed to read modules: ${err instanceof Error ? err.message : 'Unknown error'}`);
result.errors.push(
`Failed to read modules: ${err instanceof Error ? err.message : 'Unknown error'}`,
);
}
return result;
@@ -194,7 +233,12 @@ async function migrateMembers(
supabase: any,
onProgress?: (step: string, count: number) => void,
): Promise<MigrationResult> {
const result: MigrationResult = { step: 'members', success: true, count: 0, errors: [] };
const result: MigrationResult = {
step: 'members',
success: true,
count: 0,
errors: [],
};
try {
const [rows] = await mysql.execute('SELECT * FROM ve_mitglieder');
@@ -209,12 +253,16 @@ async function migrateMembers(
// beitragskategorie→dues_category_id, iban→iban, bic→bic
result.count++;
} catch (err) {
result.errors.push(`Member ${row.nachname}: ${err instanceof Error ? err.message : 'Unknown error'}`);
result.errors.push(
`Member ${row.nachname}: ${err instanceof Error ? err.message : 'Unknown error'}`,
);
}
}
} catch (err) {
result.success = false;
result.errors.push(`Failed to read members: ${err instanceof Error ? err.message : 'Unknown error'}`);
result.errors.push(
`Failed to read members: ${err instanceof Error ? err.message : 'Unknown error'}`,
);
}
return result;
@@ -225,7 +273,12 @@ async function migrateCourses(
supabase: any,
onProgress?: (step: string, count: number) => void,
): Promise<MigrationResult> {
const result: MigrationResult = { step: 'courses', success: true, count: 0, errors: [] };
const result: MigrationResult = {
step: 'courses',
success: true,
count: 0,
errors: [],
};
try {
const [rows] = await mysql.execute('SELECT * FROM ve_kurse');
@@ -238,12 +291,16 @@ async function migrateCourses(
// beginn→start_date, ende→end_date, gebuehr→fee, max_teilnehmer→capacity
result.count++;
} catch (err) {
result.errors.push(`Course ${row.kursname}: ${err instanceof Error ? err.message : 'Unknown error'}`);
result.errors.push(
`Course ${row.kursname}: ${err instanceof Error ? err.message : 'Unknown error'}`,
);
}
}
} catch (err) {
result.success = false;
result.errors.push(`Failed to read courses: ${err instanceof Error ? err.message : 'Unknown error'}`);
result.errors.push(
`Failed to read courses: ${err instanceof Error ? err.message : 'Unknown error'}`,
);
}
return result;

View File

@@ -1,12 +1,14 @@
'use client';
import { useEffect, useEffectEvent } from 'react';
import { useCallback, useEffect, useEffectEvent, useState } from 'react';
import { useRouter } from 'next/navigation';
import type { Factor } from '@supabase/supabase-js';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query';
import { TriangleAlert } from 'lucide-react';
import { Fingerprint, KeyRound, TriangleAlert } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
@@ -44,49 +46,58 @@ export function MultiFactorChallengeContainer({
};
}>) {
const router = useRouter();
const [selectedFactor, setSelectedFactor] = useState<Factor | null>(null);
const verifyMFAChallenge = useVerifyMFAChallenge({
onSuccess: () => {
router.replace(paths.redirectPath);
},
});
const onSuccess = useCallback(() => {
router.replace(paths.redirectPath);
}, [router, paths.redirectPath]);
if (!selectedFactor) {
return (
<FactorsListContainer userId={userId} onSelect={setSelectedFactor} />
);
}
if (selectedFactor.factor_type === 'webauthn') {
return (
<WebAuthnChallengeView
factor={selectedFactor}
onSuccess={onSuccess}
onBack={() => setSelectedFactor(null)}
/>
);
}
return <TotpChallengeView factor={selectedFactor} onSuccess={onSuccess} />;
}
function TotpChallengeView({
factor,
onSuccess,
}: {
factor: Factor;
onSuccess: () => void;
}) {
const verifyMFAChallenge = useVerifyMFAChallenge({ onSuccess });
const verificationCodeForm = useForm({
resolver: zodResolver(
z.object({
factorId: z.string().min(1),
verificationCode: z.string().min(6).max(6),
}),
),
defaultValues: {
factorId: '',
verificationCode: '',
},
});
const factorId = useWatch({
name: 'factorId',
control: verificationCodeForm.control,
});
if (!factorId) {
return (
<FactorsListContainer
userId={userId}
onSelect={(factorId) => {
verificationCodeForm.setValue('factorId', factorId);
}}
/>
);
}
return (
<Form {...verificationCodeForm}>
<form
className={'w-full'}
onSubmit={verificationCodeForm.handleSubmit(async (data) => {
await verifyMFAChallenge.mutateAsync({
factorId,
factorId: factor.id,
verificationCode: data.verificationCode,
});
})}
@@ -191,6 +202,97 @@ export function MultiFactorChallengeContainer({
);
}
function WebAuthnChallengeView({
factor,
onSuccess,
onBack,
}: {
factor: Factor;
onSuccess: () => void;
onBack: () => void;
}) {
const authenticatePasskey = useAuthenticatePasskey({ onSuccess });
const [autoTriggered, setAutoTriggered] = useState(false);
// Auto-trigger the passkey challenge on mount
useEffect(() => {
if (!autoTriggered) {
setAutoTriggered(true);
authenticatePasskey.mutate(factor.id);
}
}, [autoTriggered, authenticatePasskey, factor.id]);
return (
<div className={'flex flex-col items-center gap-y-6'}>
<div className="flex flex-col items-center gap-y-4">
<Heading level={5}>
<Trans i18nKey={'account.passkeyVerifyHeading'} />
</Heading>
</div>
<If condition={authenticatePasskey.error}>
<Alert variant={'destructive'}>
<TriangleAlert className={'h-5'} />
<AlertTitle>
<Trans i18nKey={'account.passkeyVerifyErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account.passkeyVerifyErrorDescription'} />
</AlertDescription>
</Alert>
</If>
<div
className={'flex flex-col items-center space-y-2 rounded-lg border p-6'}
>
<Fingerprint className={'text-muted-foreground h-12 w-12'} />
<p className={'text-muted-foreground text-center text-sm'}>
<If condition={authenticatePasskey.isPending}>
<Trans i18nKey={'account.passkeyVerifyWaiting'} />
</If>
<If condition={authenticatePasskey.isSuccess}>
<Trans i18nKey={'auth.redirecting'} />
</If>
<If
condition={
!authenticatePasskey.isPending && !authenticatePasskey.isSuccess
}
>
<Trans i18nKey={'account.passkeyVerifyPrompt'} />
</If>
</p>
</div>
<div className={'flex w-full gap-2'}>
<Button
variant={'outline'}
className={'flex-1'}
onClick={onBack}
disabled={authenticatePasskey.isPending}
>
<Trans i18nKey={'common.back'} />
</Button>
<Button
className={'flex-1'}
onClick={() => authenticatePasskey.mutate(factor.id)}
disabled={
authenticatePasskey.isPending || authenticatePasskey.isSuccess
}
>
<Fingerprint className={'h-4'} />
<Trans i18nKey={'account.passkeyRetry'} />
</Button>
</div>
</div>
);
}
function useVerifyMFAChallenge({ onSuccess }: { onSuccess: () => void }) {
const client = useSupabase();
const mutationKey = ['mfa-verify-challenge'];
@@ -216,12 +318,31 @@ function useVerifyMFAChallenge({ onSuccess }: { onSuccess: () => void }) {
return useMutation({ mutationKey, mutationFn, onSuccess });
}
function useAuthenticatePasskey({ onSuccess }: { onSuccess: () => void }) {
const client = useSupabase();
const mutationKey = ['mfa-webauthn-authenticate'];
const mutationFn = async (factorId: string) => {
const { data, error } = await client.auth.mfa.webauthn.authenticate({
factorId,
});
if (error) {
throw error;
}
return data;
};
return useMutation({ mutationKey, mutationFn, onSuccess });
}
function FactorsListContainer({
onSelect,
userId,
}: React.PropsWithChildren<{
userId: string;
onSelect: (factor: string) => void;
onSelect: (factor: Factor) => void;
}>) {
const signOut = useSignOut();
const { data: factors, isLoading, error } = useFetchAuthFactors(userId);
@@ -240,13 +361,16 @@ function FactorsListContainer({
}, [error]);
useEffect(() => {
// If there is only one factor, select it automatically
if (isSuccess && factors.totp.length === 1) {
const factorId = factors.totp[0]?.id;
if (!isSuccess) return;
if (factorId) {
onSelect(factorId);
}
const allVerified = [
...(factors.totp ?? []),
...((factors as Record<string, Factor[]>).webauthn ?? []),
];
// If there is only one factor, select it automatically
if (allVerified.length === 1 && allVerified[0]) {
onSelect(allVerified[0]);
}
});
@@ -280,7 +404,9 @@ function FactorsListContainer({
);
}
const verifiedFactors = factors?.totp ?? [];
const totpFactors = factors?.totp ?? [];
const webauthnFactors =
(factors as Record<string, Factor[]> | undefined)?.webauthn ?? [];
return (
<div className={'animate-in fade-in flex flex-col space-y-4 duration-500'}>
@@ -291,13 +417,27 @@ function FactorsListContainer({
</div>
<div className={'flex flex-col space-y-2'}>
{verifiedFactors.map((factor) => (
{totpFactors.map((factor) => (
<div key={factor.id}>
<Button
variant={'outline'}
className={'w-full'}
onClick={() => onSelect(factor.id)}
className={'w-full justify-start gap-2'}
onClick={() => onSelect(factor)}
>
<KeyRound className={'h-4 w-4'} />
{factor.friendly_name}
</Button>
</div>
))}
{webauthnFactors.map((factor) => (
<div key={factor.id}>
<Button
variant={'outline'}
className={'w-full justify-start gap-2'}
onClick={() => onSelect(factor)}
>
<Fingerprint className={'h-4 w-4'} />
{factor.friendly_name}
</Button>
</div>

View File

@@ -1,21 +1,36 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { CreateBookingSchema } from '../schema/booking.schema';
import { createBooking } from '../server/actions/booking-actions';
interface Props {
accountId: string;
account: string;
rooms: Array<{ id: string; roomNumber: string; name?: string; pricePerNight: number }>;
rooms: Array<{
id: string;
roomNumber: string;
name?: string;
pricePerNight: number;
}>;
}
export function CreateBookingForm({ accountId, account, rooms }: Props) {
@@ -49,80 +64,188 @@ export function CreateBookingForm({ accountId, account, rooms }: Props) {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="space-y-6"
>
<Card>
<CardHeader><CardTitle>Zimmer & Zeitraum</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Zimmer & Zeitraum</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="roomId" render={({ field }) => (
<FormItem><FormLabel>Zimmer *</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value=""> Zimmer wählen </option>
{rooms.map(r => (
<option key={r.id} value={r.id}>
{r.roomNumber}{r.name ? ` ${r.name}` : ''} ({r.pricePerNight} /Nacht)
</option>
))}
</select>
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="checkIn" render={({ field }) => (
<FormItem><FormLabel>Check-in *</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="checkOut" render={({ field }) => (
<FormItem><FormLabel>Check-out *</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="roomId"
render={({ field }) => (
<FormItem>
<FormLabel>Zimmer *</FormLabel>
<FormControl>
<select
{...field}
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value=""> Zimmer wählen </option>
{rooms.map((r) => (
<option key={r.id} value={r.id}>
{r.roomNumber}
{r.name ? ` ${r.name}` : ''} ({r.pricePerNight}{' '}
/Nacht)
</option>
))}
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="checkIn"
render={({ field }) => (
<FormItem>
<FormLabel>Check-in *</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="checkOut"
render={({ field }) => (
<FormItem>
<FormLabel>Check-out *</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Gäste</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Gäste</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="adults" render={({ field }) => (
<FormItem><FormLabel>Erwachsene *</FormLabel><FormControl>
<Input type="number" min={1} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="children" render={({ field }) => (
<FormItem><FormLabel>Kinder</FormLabel><FormControl>
<Input type="number" min={0} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="adults"
render={({ field }) => (
<FormItem>
<FormLabel>Erwachsene *</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="children"
render={({ field }) => (
<FormItem>
<FormLabel>Kinder</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Preis & Notizen</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Preis & Notizen</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="totalPrice" render={({ field }) => (
<FormItem><FormLabel>Gesamtpreis ()</FormLabel><FormControl>
<Input type="number" min={0} step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="status" render={({ field }) => (
<FormItem><FormLabel>Status</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="pending">Ausstehend</option>
<option value="confirmed">Bestätigt</option>
<option value="checked_in">Eingecheckt</option>
<option value="checked_out">Ausgecheckt</option>
<option value="cancelled">Storniert</option>
<option value="no_show">Nicht erschienen</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="totalPrice"
render={({ field }) => (
<FormItem>
<FormLabel>Gesamtpreis ()</FormLabel>
<FormControl>
<Input
type="number"
min={0}
step="0.01"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<FormControl>
<select
{...field}
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value="pending">Ausstehend</option>
<option value="confirmed">Bestätigt</option>
<option value="checked_in">Eingecheckt</option>
<option value="checked_out">Ausgecheckt</option>
<option value="cancelled">Storniert</option>
<option value="no_show">Nicht erschienen</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="sm:col-span-2">
<FormField control={form.control} name="notes" render={({ field }) => (
<FormItem><FormLabel>Notizen</FormLabel><FormControl>
<textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notizen</FormLabel>
<FormControl>
<textarea
{...field}
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Buchung erstellen'}</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird erstellt...' : 'Buchung erstellen'}
</Button>
</div>
</form>
</Form>

View File

@@ -1,6 +1,13 @@
import { z } from 'zod';
export const BookingStatusEnum = z.enum(['pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled', 'no_show']);
export const BookingStatusEnum = z.enum([
'pending',
'confirmed',
'checked_in',
'checked_out',
'cancelled',
'no_show',
]);
export const CreateRoomSchema = z.object({
accountId: z.string().uuid(),

View File

@@ -1,9 +1,11 @@
'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
CreateBookingSchema,
CreateGuestSchema,

View File

@@ -1,6 +1,7 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type { CreateBookingInput } from '../schema/booking.schema';
/* eslint-disable @typescript-eslint/no-explicit-any */
@@ -11,21 +12,31 @@ export function createBookingManagementApi(client: SupabaseClient<Database>) {
return {
// --- Rooms ---
async listRooms(accountId: string) {
const { data, error } = await client.from('rooms').select('*')
.eq('account_id', accountId).eq('is_active', true).order('room_number');
const { data, error } = await client
.from('rooms')
.select('*')
.eq('account_id', accountId)
.eq('is_active', true)
.order('room_number');
if (error) throw error;
return data ?? [];
},
async getRoom(roomId: string) {
const { data, error } = await client.from('rooms').select('*').eq('id', roomId).single();
const { data, error } = await client
.from('rooms')
.select('*')
.eq('id', roomId)
.single();
if (error) throw error;
return data;
},
// --- Availability ---
async checkAvailability(roomId: string, checkIn: string, checkOut: string) {
const { count, error } = await client.from('bookings').select('*', { count: 'exact', head: true })
const { count, error } = await client
.from('bookings')
.select('*', { count: 'exact', head: true })
.eq('room_id', roomId)
.not('status', 'in', '("cancelled","no_show")')
.lt('check_in', checkOut)
@@ -35,9 +46,15 @@ export function createBookingManagementApi(client: SupabaseClient<Database>) {
},
// --- Bookings ---
async listBookings(accountId: string, opts?: { status?: string; from?: string; to?: string; page?: number }) {
let query = client.from('bookings').select('*', { count: 'exact' })
.eq('account_id', accountId).order('check_in', { ascending: false });
async listBookings(
accountId: string,
opts?: { status?: string; from?: string; to?: string; page?: number },
) {
let query = client
.from('bookings')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('check_in', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status);
if (opts?.from) query = query.gte('check_in', opts.from);
if (opts?.to) query = query.lte('check_out', opts.to);
@@ -49,48 +66,106 @@ export function createBookingManagementApi(client: SupabaseClient<Database>) {
},
async createBooking(input: CreateBookingInput) {
const available = await this.checkAvailability(input.roomId, input.checkIn, input.checkOut);
if (!available) throw new Error('Room is not available for the selected dates');
const available = await this.checkAvailability(
input.roomId,
input.checkIn,
input.checkOut,
);
if (!available)
throw new Error('Room is not available for the selected dates');
const { data, error } = await client.from('bookings').insert({
account_id: input.accountId, room_id: input.roomId, guest_id: input.guestId,
check_in: input.checkIn, check_out: input.checkOut,
adults: input.adults, children: input.children,
status: input.status, total_price: input.totalPrice, notes: input.notes,
}).select().single();
const { data, error } = await client
.from('bookings')
.insert({
account_id: input.accountId,
room_id: input.roomId,
guest_id: input.guestId,
check_in: input.checkIn,
check_out: input.checkOut,
adults: input.adults,
children: input.children,
status: input.status,
total_price: input.totalPrice,
notes: input.notes,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateBookingStatus(bookingId: string, status: string) {
const { error } = await client.from('bookings').update({ status }).eq('id', bookingId);
const { error } = await client
.from('bookings')
.update({ status })
.eq('id', bookingId);
if (error) throw error;
},
// --- Guests ---
async listGuests(accountId: string, search?: string) {
let query = client.from('guests').select('*').eq('account_id', accountId).order('last_name');
if (search) query = query.or(`last_name.ilike.%${search}%,first_name.ilike.%${search}%,email.ilike.%${search}%`);
let query = client
.from('guests')
.select('*')
.eq('account_id', accountId)
.order('last_name');
if (search)
query = query.or(
`last_name.ilike.%${search}%,first_name.ilike.%${search}%,email.ilike.%${search}%`,
);
const { data, error } = await query;
if (error) throw error;
return data ?? [];
},
async createGuest(input: { accountId: string; firstName: string; lastName: string; email?: string; phone?: string; city?: string }) {
const { data, error } = await client.from('guests').insert({
account_id: input.accountId, first_name: input.firstName, last_name: input.lastName,
email: input.email, phone: input.phone, city: input.city,
}).select().single();
async createGuest(input: {
accountId: string;
firstName: string;
lastName: string;
email?: string;
phone?: string;
city?: string;
}) {
const { data, error } = await client
.from('guests')
.insert({
account_id: input.accountId,
first_name: input.firstName,
last_name: input.lastName,
email: input.email,
phone: input.phone,
city: input.city,
})
.select()
.single();
if (error) throw error;
return data;
},
async createRoom(input: { accountId: string; roomNumber: string; name?: string; roomType?: string; capacity?: number; floor?: number; pricePerNight: number; description?: string }) {
const { data, error } = await client.from('rooms').insert({
account_id: input.accountId, room_number: input.roomNumber, name: input.name,
room_type: input.roomType ?? 'standard', capacity: input.capacity ?? 2,
floor: input.floor, price_per_night: input.pricePerNight, description: input.description,
}).select().single();
async createRoom(input: {
accountId: string;
roomNumber: string;
name?: string;
roomType?: string;
capacity?: number;
floor?: number;
pricePerNight: number;
description?: string;
}) {
const { data, error } = await client
.from('rooms')
.insert({
account_id: input.accountId,
room_number: input.roomNumber,
name: input.name,
room_type: input.roomType ?? 'standard',
capacity: input.capacity ?? 2,
floor: input.floor,
price_per_night: input.pricePerNight,
description: input.description,
})
.select()
.single();
if (error) throw error;
return data;
},

View File

@@ -1,6 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "*.tsx", "src"],
"exclude": ["node_modules"]
}

View File

@@ -1,14 +1,24 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { CreateCourseSchema } from '../schema/course.schema';
import { createCourse } from '../server/actions/course-actions';
@@ -52,94 +62,246 @@ export function CreateCourseForm({ accountId, account }: Props) {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="space-y-6"
>
<Card>
<CardHeader><CardTitle>Grunddaten</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Grunddaten</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="courseNumber" render={({ field }) => (
<FormItem><FormLabel>Kursnummer</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="name" render={({ field }) => (
<FormItem><FormLabel>Kursname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="courseNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Kursnummer</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Kursname *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="sm:col-span-2">
<FormField control={form.control} name="description" render={({ field }) => (
<FormItem><FormLabel>Beschreibung</FormLabel><FormControl>
<textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung</FormLabel>
<FormControl>
<textarea
{...field}
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Zeitplan</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Zeitplan</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="startDate" render={({ field }) => (
<FormItem><FormLabel>Startdatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="endDate" render={({ field }) => (
<FormItem><FormLabel>Enddatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="registrationDeadline" render={({ field }) => (
<FormItem><FormLabel>Anmeldeschluss</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="startDate"
render={({ field }) => (
<FormItem>
<FormLabel>Startdatum</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endDate"
render={({ field }) => (
<FormItem>
<FormLabel>Enddatum</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="registrationDeadline"
render={({ field }) => (
<FormItem>
<FormLabel>Anmeldeschluss</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Kapazität</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Kapazität</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="capacity" render={({ field }) => (
<FormItem><FormLabel>Max. Teilnehmer</FormLabel><FormControl>
<Input type="number" min={1} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="minParticipants" render={({ field }) => (
<FormItem><FormLabel>Min. Teilnehmer</FormLabel><FormControl>
<Input type="number" min={0} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="fee" render={({ field }) => (
<FormItem><FormLabel>Gebühr ()</FormLabel><FormControl>
<Input type="number" min={0} step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="reducedFee" render={({ field }) => (
<FormItem><FormLabel>Ermäßigte Gebühr ()</FormLabel><FormControl>
<Input type="number" min={0} step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="capacity"
render={({ field }) => (
<FormItem>
<FormLabel>Max. Teilnehmer</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="minParticipants"
render={({ field }) => (
<FormItem>
<FormLabel>Min. Teilnehmer</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fee"
render={({ field }) => (
<FormItem>
<FormLabel>Gebühr ()</FormLabel>
<FormControl>
<Input
type="number"
min={0}
step="0.01"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="reducedFee"
render={({ field }) => (
<FormItem>
<FormLabel>Ermäßigte Gebühr ()</FormLabel>
<FormControl>
<Input
type="number"
min={0}
step="0.01"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Status</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Status</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="status" render={({ field }) => (
<FormItem><FormLabel>Kursstatus</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<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>
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Kursstatus</FormLabel>
<FormControl>
<select
{...field}
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<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>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="sm:col-span-1">
<FormField control={form.control} name="notes" render={({ field }) => (
<FormItem><FormLabel>Notizen</FormLabel><FormControl>
<textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notizen</FormLabel>
<FormControl>
<textarea
{...field}
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Kurs erstellen'}</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird erstellt...' : 'Kurs erstellen'}
</Button>
</div>
</form>
</Form>

View File

@@ -1,9 +1,20 @@
import { z } from 'zod';
export const EnrollmentStatusEnum = z.enum(['enrolled', 'waitlisted', 'cancelled', 'completed']);
export const EnrollmentStatusEnum = z.enum([
'enrolled',
'waitlisted',
'cancelled',
'completed',
]);
export type EnrollmentStatus = z.infer<typeof EnrollmentStatusEnum>;
export const CourseStatusEnum = z.enum(['planned', 'open', 'running', 'completed', 'cancelled']);
export const CourseStatusEnum = z.enum([
'planned',
'open',
'running',
'completed',
'cancelled',
]);
export const CreateCourseSchema = z.object({
accountId: z.string().uuid(),

View File

@@ -1,9 +1,11 @@
'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
CreateCourseSchema,
EnrollParticipantSchema,
@@ -34,7 +36,10 @@ export const enrollParticipant = authActionClient
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.enrollParticipant' }, 'Enrolling participant...');
logger.info(
{ name: 'course.enrollParticipant' },
'Enrolling participant...',
);
const result = await api.enrollParticipant(input);
logger.info({ name: 'course.enrollParticipant' }, 'Participant enrolled');
return { success: true, data: result };
@@ -51,7 +56,10 @@ export const cancelEnrollment = authActionClient
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.cancelEnrollment' }, 'Cancelling enrollment...');
logger.info(
{ name: 'course.cancelEnrollment' },
'Cancelling enrollment...',
);
const result = await api.cancelEnrollment(input.participantId);
logger.info({ name: 'course.cancelEnrollment' }, 'Enrollment cancelled');
return { success: true, data: result };
@@ -71,7 +79,11 @@ export const markAttendance = authActionClient
const api = createCourseManagementApi(client);
logger.info({ name: 'course.markAttendance' }, 'Marking attendance...');
const result = await api.markAttendance(input.sessionId, input.participantId, input.present);
const result = await api.markAttendance(
input.sessionId,
input.participantId,
input.present,
);
logger.info({ name: 'course.markAttendance' }, 'Attendance marked');
return { success: true, data: result };
});

View File

@@ -1,7 +1,11 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { CreateCourseInput, EnrollParticipantInput } from '../schema/course.schema';
import type { Database } from '@kit/supabase/database';
import type {
CreateCourseInput,
EnrollParticipantInput,
} from '../schema/course.schema';
/* eslint-disable @typescript-eslint/no-explicit-any */
@@ -10,11 +14,25 @@ export function createCourseManagementApi(client: SupabaseClient<Database>) {
return {
// --- Courses ---
async listCourses(accountId: string, opts?: { status?: string; search?: string; page?: number; pageSize?: number }) {
let query = client.from('courses').select('*', { count: 'exact' })
.eq('account_id', accountId).order('start_date', { ascending: false });
async listCourses(
accountId: string,
opts?: {
status?: string;
search?: string;
page?: number;
pageSize?: number;
},
) {
let query = client
.from('courses')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('start_date', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status);
if (opts?.search) query = query.or(`name.ilike.%${opts.search}%,course_number.ilike.%${opts.search}%`);
if (opts?.search)
query = query.or(
`name.ilike.%${opts.search}%,course_number.ilike.%${opts.search}%`,
);
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 25;
query = query.range((page - 1) * pageSize, page * pageSize - 1);
@@ -24,20 +42,38 @@ export function createCourseManagementApi(client: SupabaseClient<Database>) {
},
async getCourse(courseId: string) {
const { data, error } = await client.from('courses').select('*').eq('id', courseId).single();
const { data, error } = await client
.from('courses')
.select('*')
.eq('id', courseId)
.single();
if (error) throw error;
return data;
},
async createCourse(input: CreateCourseInput) {
const { data, error } = await client.from('courses').insert({
account_id: input.accountId, course_number: input.courseNumber, name: input.name,
description: input.description, category_id: input.categoryId, instructor_id: input.instructorId,
location_id: input.locationId, start_date: input.startDate, end_date: input.endDate,
fee: input.fee, reduced_fee: input.reducedFee, capacity: input.capacity,
min_participants: input.minParticipants, status: input.status,
registration_deadline: input.registrationDeadline, notes: input.notes,
}).select().single();
const { data, error } = await client
.from('courses')
.insert({
account_id: input.accountId,
course_number: input.courseNumber,
name: input.name,
description: input.description,
category_id: input.categoryId,
instructor_id: input.instructorId,
location_id: input.locationId,
start_date: input.startDate,
end_date: input.endDate,
fee: input.fee,
reduced_fee: input.reducedFee,
capacity: input.capacity,
min_participants: input.minParticipants,
status: input.status,
registration_deadline: input.registrationDeadline,
notes: input.notes,
})
.select()
.single();
if (error) throw error;
return data;
},
@@ -45,96 +81,161 @@ export function createCourseManagementApi(client: SupabaseClient<Database>) {
// --- Enrollment ---
async enrollParticipant(input: EnrollParticipantInput) {
// Check capacity
const { count } = await client.from('course_participants').select('*', { count: 'exact', head: true })
.eq('course_id', input.courseId).in('status', ['enrolled']);
const { count } = await client
.from('course_participants')
.select('*', { count: 'exact', head: true })
.eq('course_id', input.courseId)
.in('status', ['enrolled']);
const course = await this.getCourse(input.courseId);
const status = (count ?? 0) >= course.capacity ? 'waitlisted' : 'enrolled';
const status =
(count ?? 0) >= course.capacity ? 'waitlisted' : 'enrolled';
const { data, error } = await client.from('course_participants').insert({
course_id: input.courseId, member_id: input.memberId,
first_name: input.firstName, last_name: input.lastName,
email: input.email, phone: input.phone, status,
}).select().single();
const { data, error } = await client
.from('course_participants')
.insert({
course_id: input.courseId,
member_id: input.memberId,
first_name: input.firstName,
last_name: input.lastName,
email: input.email,
phone: input.phone,
status,
})
.select()
.single();
if (error) throw error;
return data;
},
async cancelEnrollment(participantId: string) {
const { error } = await client.from('course_participants')
const { error } = await client
.from('course_participants')
.update({ status: 'cancelled', cancelled_at: new Date().toISOString() })
.eq('id', participantId);
if (error) throw error;
},
async getParticipants(courseId: string) {
const { data, error } = await client.from('course_participants').select('*')
.eq('course_id', courseId).order('enrolled_at');
const { data, error } = await client
.from('course_participants')
.select('*')
.eq('course_id', courseId)
.order('enrolled_at');
if (error) throw error;
return data ?? [];
},
// --- Sessions ---
async getSessions(courseId: string) {
const { data, error } = await client.from('course_sessions').select('*')
.eq('course_id', courseId).order('session_date');
const { data, error } = await client
.from('course_sessions')
.select('*')
.eq('course_id', courseId)
.order('session_date');
if (error) throw error;
return data ?? [];
},
async createSession(input: { courseId: string; sessionDate: string; startTime: string; endTime: string; locationId?: string }) {
const { data, error } = await client.from('course_sessions').insert({
course_id: input.courseId, session_date: input.sessionDate,
start_time: input.startTime, end_time: input.endTime, location_id: input.locationId,
}).select().single();
async createSession(input: {
courseId: string;
sessionDate: string;
startTime: string;
endTime: string;
locationId?: string;
}) {
const { data, error } = await client
.from('course_sessions')
.insert({
course_id: input.courseId,
session_date: input.sessionDate,
start_time: input.startTime,
end_time: input.endTime,
location_id: input.locationId,
})
.select()
.single();
if (error) throw error;
return data;
},
// --- Attendance ---
async getAttendance(sessionId: string) {
const { data, error } = await client.from('course_attendance').select('*').eq('session_id', sessionId);
const { data, error } = await client
.from('course_attendance')
.select('*')
.eq('session_id', sessionId);
if (error) throw error;
return data ?? [];
},
async markAttendance(sessionId: string, participantId: string, present: boolean) {
const { error } = await client.from('course_attendance').upsert({
session_id: sessionId, participant_id: participantId, present,
}, { onConflict: 'session_id,participant_id' });
async markAttendance(
sessionId: string,
participantId: string,
present: boolean,
) {
const { error } = await client.from('course_attendance').upsert(
{
session_id: sessionId,
participant_id: participantId,
present,
},
{ onConflict: 'session_id,participant_id' },
);
if (error) throw error;
},
// --- Categories, Instructors, Locations ---
async listCategories(accountId: string) {
const { data, error } = await client.from('course_categories').select('*')
.eq('account_id', accountId).order('sort_order');
const { data, error } = await client
.from('course_categories')
.select('*')
.eq('account_id', accountId)
.order('sort_order');
if (error) throw error;
return data ?? [];
},
async listInstructors(accountId: string) {
const { data, error } = await client.from('course_instructors').select('*')
.eq('account_id', accountId).order('last_name');
const { data, error } = await client
.from('course_instructors')
.select('*')
.eq('account_id', accountId)
.order('last_name');
if (error) throw error;
return data ?? [];
},
async listLocations(accountId: string) {
const { data, error } = await client.from('course_locations').select('*')
.eq('account_id', accountId).order('name');
const { data, error } = await client
.from('course_locations')
.select('*')
.eq('account_id', accountId)
.order('name');
if (error) throw error;
return data ?? [];
},
// --- Statistics ---
async getStatistics(accountId: string) {
const { data: courses } = await client.from('courses').select('status').eq('account_id', accountId);
const { count: totalParticipants } = await client.from('course_participants')
const { data: courses } = await client
.from('courses')
.select('status')
.eq('account_id', accountId);
const { count: totalParticipants } = await client
.from('course_participants')
.select('*', { count: 'exact', head: true })
.in('course_id', (courses ?? []).map((c: any) => c.id));
.in(
'course_id',
(courses ?? []).map((c: any) => c.id),
);
const stats = { totalCourses: 0, openCourses: 0, completedCourses: 0, totalParticipants: totalParticipants ?? 0 };
for (const c of (courses ?? [])) {
const stats = {
totalCourses: 0,
openCourses: 0,
completedCourses: 0,
totalParticipants: totalParticipants ?? 0,
};
for (const c of courses ?? []) {
stats.totalCourses++;
if (c.status === 'open' || c.status === 'running') stats.openCourses++;
if (c.status === 'completed') stats.completedCourses++;
@@ -143,30 +244,70 @@ export function createCourseManagementApi(client: SupabaseClient<Database>) {
},
// --- Create methods for CRUD ---
async createCategory(input: { accountId: string; name: string; description?: string; parentId?: string }) {
const { data, error } = await client.from('course_categories').insert({
account_id: input.accountId, name: input.name, description: input.description,
parent_id: input.parentId,
}).select().single();
async createCategory(input: {
accountId: string;
name: string;
description?: string;
parentId?: string;
}) {
const { data, error } = await client
.from('course_categories')
.insert({
account_id: input.accountId,
name: input.name,
description: input.description,
parent_id: input.parentId,
})
.select()
.single();
if (error) throw error;
return data;
},
async createInstructor(input: { accountId: string; firstName: string; lastName: string; email?: string; phone?: string; qualifications?: string; hourlyRate?: number }) {
const { data, error } = await client.from('course_instructors').insert({
account_id: input.accountId, first_name: input.firstName, last_name: input.lastName,
email: input.email, phone: input.phone, qualifications: input.qualifications,
hourly_rate: input.hourlyRate,
}).select().single();
async createInstructor(input: {
accountId: string;
firstName: string;
lastName: string;
email?: string;
phone?: string;
qualifications?: string;
hourlyRate?: number;
}) {
const { data, error } = await client
.from('course_instructors')
.insert({
account_id: input.accountId,
first_name: input.firstName,
last_name: input.lastName,
email: input.email,
phone: input.phone,
qualifications: input.qualifications,
hourly_rate: input.hourlyRate,
})
.select()
.single();
if (error) throw error;
return data;
},
async createLocation(input: { accountId: string; name: string; address?: string; room?: string; capacity?: number }) {
const { data, error } = await client.from('course_locations').insert({
account_id: input.accountId, name: input.name, address: input.address,
room: input.room, capacity: input.capacity,
}).select().single();
async createLocation(input: {
accountId: string;
name: string;
address?: string;
room?: string;
capacity?: number;
}) {
const { data, error } = await client
.from('course_locations')
.insert({
account_id: input.accountId,
name: input.name,
address: input.address,
room: input.room,
capacity: input.capacity,
})
.select()
.single();
if (error) throw error;
return data;
},

View File

@@ -1,6 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "*.tsx", "src"],
"exclude": ["node_modules"]
}

View File

@@ -2,7 +2,13 @@ import { z } from 'zod';
export const DocumentTypeEnum = z.enum(['pdf', 'excel', 'word', 'label']);
export const DocumentTemplateTypeEnum = z.enum([
'member_card', 'invoice', 'label_avery', 'report', 'letter', 'certificate', 'custom',
'member_card',
'invoice',
'label_avery',
'report',
'letter',
'certificate',
'custom',
]);
export const GenerateDocumentSchema = z.object({
@@ -11,21 +17,27 @@ export const GenerateDocumentSchema = z.object({
templateType: DocumentTemplateTypeEnum,
title: z.string().min(1),
data: z.record(z.string(), z.unknown()),
options: z.object({
format: z.enum(['A4', 'A5', 'letter', 'label']).default('A4'),
orientation: z.enum(['portrait', 'landscape']).default('portrait'),
labelFormat: z.string().optional(), // e.g. 'avery-l7163'
}).optional(),
options: z
.object({
format: z.enum(['A4', 'A5', 'letter', 'label']).default('A4'),
orientation: z.enum(['portrait', 'landscape']).default('portrait'),
labelFormat: z.string().optional(), // e.g. 'avery-l7163'
})
.optional(),
});
export type GenerateDocumentInput = z.infer<typeof GenerateDocumentSchema>;
export const GenerateBatchLabelsSchema = z.object({
accountId: z.string().uuid(),
labelFormat: z.string().default('avery-l7163'),
records: z.array(z.object({
line1: z.string(),
line2: z.string().optional(),
line3: z.string().optional(),
line4: z.string().optional(),
})).min(1),
records: z
.array(
z.object({
line1: z.string(),
line2: z.string().optional(),
line3: z.string().optional(),
line4: z.string().optional(),
}),
)
.min(1),
});

View File

@@ -20,7 +20,7 @@ export function createDocumentGeneratorApi() {
// Dynamic import to avoid bundle bloat in SSR
// Actual implementation will use @react-pdf/renderer
throw new Error(
'PDF generation requires @react-pdf/renderer. Install it and implement the renderer in pdf-generator.service.ts'
'PDF generation requires @react-pdf/renderer. Install it and implement the renderer in pdf-generator.service.ts',
);
},
@@ -37,7 +37,7 @@ export function createDocumentGeneratorApi() {
}>;
}): Promise<Uint8Array> {
throw new Error(
'Excel generation requires exceljs. Install it and implement in excel-generator.service.ts'
'Excel generation requires exceljs. Install it and implement in excel-generator.service.ts',
);
},
@@ -51,7 +51,7 @@ export function createDocumentGeneratorApi() {
mergeFields: Record<string, string>;
}): Promise<Uint8Array> {
throw new Error(
'Word generation requires docx. Install it and implement in word-generator.service.ts'
'Word generation requires docx. Install it and implement in word-generator.service.ts',
);
},
@@ -61,7 +61,12 @@ export function createDocumentGeneratorApi() {
*/
generateLabelsHtml(params: {
labelFormat: string;
records: Array<{ line1: string; line2?: string; line3?: string; line4?: string }>;
records: Array<{
line1: string;
line2?: string;
line3?: string;
line4?: string;
}>;
}): string {
const { records } = params;
@@ -71,14 +76,18 @@ export function createDocumentGeneratorApi() {
for (let i = 0; i < records.length; i += labelsPerPage) {
const pageRecords = records.slice(i, i + labelsPerPage);
const labels = pageRecords.map((r) => `
const labels = pageRecords
.map(
(r) => `
<div style="width:99.1mm;height:38.1mm;padding:4mm;box-sizing:border-box;overflow:hidden;font-size:10pt;font-family:Arial,sans-serif;">
<div>${r.line1}</div>
${r.line2 ? `<div>${r.line2}</div>` : ''}
${r.line3 ? `<div>${r.line3}</div>` : ''}
${r.line4 ? `<div>${r.line4}</div>` : ''}
</div>
`).join('');
`,
)
.join('');
pages.push(`
<div style="width:210mm;display:grid;grid-template-columns:1fr 1fr;gap:0;page-break-after:always;">

View File

@@ -1,6 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "*.tsx", "src"],
"exclude": ["node_modules"]
}

View File

@@ -1,14 +1,24 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { CreateEventSchema } from '../schema/event.schema';
import { createEvent } from '../server/actions/event-actions';
@@ -49,108 +59,305 @@ export function CreateEventForm({ accountId, account }: Props) {
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Erstellen der Veranstaltung');
toast.error(
error.serverError ?? 'Fehler beim Erstellen der Veranstaltung',
);
},
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="space-y-6"
>
<Card>
<CardHeader><CardTitle>Grunddaten</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Grunddaten</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<FormField control={form.control} name="name" render={({ field }) => (
<FormItem><FormLabel>Veranstaltungsname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Veranstaltungsname *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="sm:col-span-2">
<FormField control={form.control} name="description" render={({ field }) => (
<FormItem><FormLabel>Beschreibung</FormLabel><FormControl>
<textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung</FormLabel>
<FormControl>
<textarea
{...field}
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField control={form.control} name="status" render={({ field }) => (
<FormItem><FormLabel>Status</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="planned">Geplant</option>
<option value="open">Offen</option>
<option value="full">Ausgebucht</option>
<option value="running">Laufend</option>
<option value="completed">Abgeschlossen</option>
<option value="cancelled">Abgesagt</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<FormControl>
<select
{...field}
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value="planned">Geplant</option>
<option value="open">Offen</option>
<option value="full">Ausgebucht</option>
<option value="running">Laufend</option>
<option value="completed">Abgeschlossen</option>
<option value="cancelled">Abgesagt</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Datum & Ort</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Datum & Ort</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="eventDate" render={({ field }) => (
<FormItem><FormLabel>Veranstaltungsdatum *</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="eventTime" render={({ field }) => (
<FormItem><FormLabel>Uhrzeit</FormLabel><FormControl><Input type="time" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="endDate" render={({ field }) => (
<FormItem><FormLabel>Enddatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="location" render={({ field }) => (
<FormItem><FormLabel>Veranstaltungsort</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="registrationDeadline" render={({ field }) => (
<FormItem><FormLabel>Anmeldeschluss</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="eventDate"
render={({ field }) => (
<FormItem>
<FormLabel>Veranstaltungsdatum *</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="eventTime"
render={({ field }) => (
<FormItem>
<FormLabel>Uhrzeit</FormLabel>
<FormControl>
<Input type="time" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endDate"
render={({ field }) => (
<FormItem>
<FormLabel>Enddatum</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Veranstaltungsort</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="registrationDeadline"
render={({ field }) => (
<FormItem>
<FormLabel>Anmeldeschluss</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Teilnehmer & Kosten</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Teilnehmer & Kosten</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="capacity" render={({ field }) => (
<FormItem><FormLabel>Max. Teilnehmer</FormLabel><FormControl>
<Input type="number" min={1} {...field} value={field.value ?? ''} onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="fee" render={({ field }) => (
<FormItem><FormLabel>Gebühr ()</FormLabel><FormControl>
<Input type="number" min={0} step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="minAge" render={({ field }) => (
<FormItem><FormLabel>Mindestalter</FormLabel><FormControl>
<Input type="number" min={0} {...field} value={field.value ?? ''} onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="maxAge" render={({ field }) => (
<FormItem><FormLabel>Höchstalter</FormLabel><FormControl>
<Input type="number" min={0} {...field} value={field.value ?? ''} onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="capacity"
render={({ field }) => (
<FormItem>
<FormLabel>Max. Teilnehmer</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fee"
render={({ field }) => (
<FormItem>
<FormLabel>Gebühr ()</FormLabel>
<FormControl>
<Input
type="number"
min={0}
step="0.01"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="minAge"
render={({ field }) => (
<FormItem>
<FormLabel>Mindestalter</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxAge"
render={({ field }) => (
<FormItem>
<FormLabel>Höchstalter</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Kontakt</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Kontakt</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="contactName" render={({ field }) => (
<FormItem><FormLabel>Ansprechpartner</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="contactEmail" render={({ field }) => (
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="contactPhone" render={({ field }) => (
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input type="tel" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="contactName"
render={({ field }) => (
<FormItem>
<FormLabel>Ansprechpartner</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contactEmail"
render={({ field }) => (
<FormItem>
<FormLabel>E-Mail</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contactPhone"
render={({ field }) => (
<FormItem>
<FormLabel>Telefon</FormLabel>
<FormControl>
<Input type="tel" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Veranstaltung erstellen'}</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird erstellt...' : 'Veranstaltung erstellen'}
</Button>
</div>
</form>
</Form>

View File

@@ -1,6 +1,13 @@
import { z } from 'zod';
export const EventStatusEnum = z.enum(['planned', 'open', 'full', 'running', 'completed', 'cancelled']);
export const EventStatusEnum = z.enum([
'planned',
'open',
'full',
'running',
'completed',
'cancelled',
]);
export const CreateEventSchema = z.object({
accountId: z.string().uuid(),

View File

@@ -1,9 +1,11 @@
'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
CreateEventSchema,
EventRegistrationSchema,
@@ -44,7 +46,10 @@ export const createHolidayPass = authActionClient
const logger = await getLogger();
const api = createEventManagementApi(client);
logger.info({ name: 'event.createHolidayPass' }, 'Creating holiday pass...');
logger.info(
{ name: 'event.createHolidayPass' },
'Creating holiday pass...',
);
const result = await api.createHolidayPass(input);
logger.info({ name: 'event.createHolidayPass' }, 'Holiday pass created');
return { success: true, data: result };

View File

@@ -1,6 +1,7 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type { CreateEventInput } from '../schema/event.schema';
/* eslint-disable @typescript-eslint/no-explicit-any */
@@ -10,16 +11,28 @@ export function createEventManagementApi(client: SupabaseClient<Database>) {
const db = client;
return {
async listEvents(accountId: string, opts?: { status?: string; page?: number }) {
let query = client.from('events').select('*', { count: 'exact' })
.eq('account_id', accountId).order('event_date', { ascending: false });
async listEvents(
accountId: string,
opts?: { status?: string; page?: number },
) {
let query = client
.from('events')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('event_date', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status);
const page = opts?.page ?? 1;
query = query.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
const { data, error, count } = await query;
if (error) throw error;
const total = count ?? 0;
return { data: data ?? [], total, page, pageSize: PAGE_SIZE, totalPages: Math.max(1, Math.ceil(total / PAGE_SIZE)) };
return {
data: data ?? [],
total,
page,
pageSize: PAGE_SIZE,
totalPages: Math.max(1, Math.ceil(total / PAGE_SIZE)),
};
},
async getRegistrationCounts(eventIds: string[]) {
@@ -40,71 +53,131 @@ export function createEventManagementApi(client: SupabaseClient<Database>) {
},
async getEvent(eventId: string) {
const { data, error } = await client.from('events').select('*').eq('id', eventId).single();
const { data, error } = await client
.from('events')
.select('*')
.eq('id', eventId)
.single();
if (error) throw error;
return data;
},
async createEvent(input: CreateEventInput) {
const { data, error } = await client.from('events').insert({
account_id: input.accountId, name: input.name, description: input.description,
event_date: input.eventDate, event_time: input.eventTime, end_date: input.endDate,
location: input.location, capacity: input.capacity, min_age: input.minAge,
max_age: input.maxAge, fee: input.fee, status: input.status,
registration_deadline: input.registrationDeadline,
contact_name: input.contactName, contact_email: input.contactEmail, contact_phone: input.contactPhone,
}).select().single();
const { data, error } = await client
.from('events')
.insert({
account_id: input.accountId,
name: input.name,
description: input.description,
event_date: input.eventDate,
event_time: input.eventTime,
end_date: input.endDate,
location: input.location,
capacity: input.capacity,
min_age: input.minAge,
max_age: input.maxAge,
fee: input.fee,
status: input.status,
registration_deadline: input.registrationDeadline,
contact_name: input.contactName,
contact_email: input.contactEmail,
contact_phone: input.contactPhone,
})
.select()
.single();
if (error) throw error;
return data;
},
async registerForEvent(input: { eventId: string; firstName: string; lastName: string; email?: string; parentName?: string }) {
async registerForEvent(input: {
eventId: string;
firstName: string;
lastName: string;
email?: string;
parentName?: string;
}) {
// Check capacity
const event = await this.getEvent(input.eventId);
if (event.capacity) {
const { count } = await client.from('event_registrations').select('*', { count: 'exact', head: true })
.eq('event_id', input.eventId).in('status', ['pending', 'confirmed']);
const { count } = await client
.from('event_registrations')
.select('*', { count: 'exact', head: true })
.eq('event_id', input.eventId)
.in('status', ['pending', 'confirmed']);
if ((count ?? 0) >= event.capacity) {
throw new Error('Event is full');
}
}
const { data, error } = await client.from('event_registrations').insert({
event_id: input.eventId, first_name: input.firstName, last_name: input.lastName,
email: input.email, parent_name: input.parentName, status: 'confirmed',
}).select().single();
const { data, error } = await client
.from('event_registrations')
.insert({
event_id: input.eventId,
first_name: input.firstName,
last_name: input.lastName,
email: input.email,
parent_name: input.parentName,
status: 'confirmed',
})
.select()
.single();
if (error) throw error;
return data;
},
async getRegistrations(eventId: string) {
const { data, error } = await client.from('event_registrations').select('*')
.eq('event_id', eventId).order('created_at');
const { data, error } = await client
.from('event_registrations')
.select('*')
.eq('event_id', eventId)
.order('created_at');
if (error) throw error;
return data ?? [];
},
// Holiday passes
async listHolidayPasses(accountId: string) {
const { data, error } = await client.from('holiday_passes').select('*')
.eq('account_id', accountId).order('year', { ascending: false });
const { data, error } = await client
.from('holiday_passes')
.select('*')
.eq('account_id', accountId)
.order('year', { ascending: false });
if (error) throw error;
return data ?? [];
},
async getPassActivities(passId: string) {
const { data, error } = await client.from('holiday_pass_activities').select('*')
.eq('pass_id', passId).order('activity_date');
const { data, error } = await client
.from('holiday_pass_activities')
.select('*')
.eq('pass_id', passId)
.order('activity_date');
if (error) throw error;
return data ?? [];
},
async createHolidayPass(input: { accountId: string; name: string; year: number; description?: string; price?: number; validFrom?: string; validUntil?: string }) {
const { data, error } = await client.from('holiday_passes').insert({
account_id: input.accountId, name: input.name, year: input.year,
description: input.description, price: input.price ?? 0,
valid_from: input.validFrom, valid_until: input.validUntil,
}).select().single();
async createHolidayPass(input: {
accountId: string;
name: string;
year: number;
description?: string;
price?: number;
validFrom?: string;
validUntil?: string;
}) {
const { data, error } = await client
.from('holiday_passes')
.insert({
account_id: input.accountId,
name: input.name,
year: input.year,
description: input.description,
price: input.price ?? 0,
valid_from: input.validFrom,
valid_until: input.validUntil,
})
.select()
.single();
if (error) throw error;
return data;
},

View File

@@ -1,6 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "*.tsx", "src"],
"exclude": ["node_modules"]
}

View File

@@ -1,15 +1,26 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, useFieldArray } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm, useFieldArray } from 'react-hook-form';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { CreateInvoiceSchema } from '../schema/finance.schema';
import { createInvoice } from '../server/actions/finance-actions';
@@ -64,37 +75,98 @@ export function CreateInvoiceForm({ accountId, account }: Props) {
});
const formatCurrency = (value: number) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value);
new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(value);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="space-y-6"
>
<Card>
<CardHeader><CardTitle>Rechnungsdaten</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Rechnungsdaten</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="invoiceNumber" render={({ field }) => (
<FormItem><FormLabel>Rechnungsnummer *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="issueDate" render={({ field }) => (
<FormItem><FormLabel>Rechnungsdatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="dueDate" render={({ field }) => (
<FormItem><FormLabel>Fälligkeitsdatum *</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="invoiceNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Rechnungsnummer *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="issueDate"
render={({ field }) => (
<FormItem>
<FormLabel>Rechnungsdatum</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dueDate"
render={({ field }) => (
<FormItem>
<FormLabel>Fälligkeitsdatum *</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Empfänger</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Empfänger</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="recipientName" render={({ field }) => (
<FormItem><FormLabel>Name *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="recipientAddress" render={({ field }) => (
<FormItem><FormLabel>Adresse</FormLabel><FormControl>
<textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="recipientName"
render={({ field }) => (
<FormItem>
<FormLabel>Name *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="recipientAddress"
render={({ field }) => (
<FormItem>
<FormLabel>Adresse</FormLabel>
<FormControl>
<textarea
{...field}
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
@@ -106,7 +178,9 @@ export function CreateInvoiceForm({ accountId, account }: Props) {
type="button"
variant="outline"
size="sm"
onClick={() => append({ description: '', quantity: 1, unitPrice: 0 })}
onClick={() =>
append({ description: '', quantity: 1, unitPrice: 0 })
}
>
+ Position hinzufügen
</Button>
@@ -114,29 +188,79 @@ export function CreateInvoiceForm({ accountId, account }: Props) {
</CardHeader>
<CardContent className="space-y-4">
{fields.map((item, index) => (
<div key={item.id} className="grid grid-cols-1 gap-4 rounded-lg border p-4 sm:grid-cols-12">
<div
key={item.id}
className="grid grid-cols-1 gap-4 rounded-lg border p-4 sm:grid-cols-12"
>
<div className="sm:col-span-6">
<FormField control={form.control} name={`items.${index}.description`} render={({ field }) => (
<FormItem><FormLabel>Beschreibung *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name={`items.${index}.description`}
render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="sm:col-span-2">
<FormField control={form.control} name={`items.${index}.quantity`} render={({ field }) => (
<FormItem><FormLabel>Menge</FormLabel><FormControl>
<Input type="number" min={0.01} step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name={`items.${index}.quantity`}
render={({ field }) => (
<FormItem>
<FormLabel>Menge</FormLabel>
<FormControl>
<Input
type="number"
min={0.01}
step="0.01"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="sm:col-span-3">
<FormField control={form.control} name={`items.${index}.unitPrice`} render={({ field }) => (
<FormItem><FormLabel>Einzelpreis ()</FormLabel><FormControl>
<Input type="number" step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name={`items.${index}.unitPrice`}
render={({ field }) => (
<FormItem>
<FormLabel>Einzelpreis ()</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex items-end sm:col-span-1">
{fields.length > 1 && (
<Button type="button" variant="ghost" size="sm" onClick={() => remove(index)} className="text-destructive">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => remove(index)}
className="text-destructive"
>
</Button>
)}
@@ -147,18 +271,31 @@ export function CreateInvoiceForm({ accountId, account }: Props) {
</Card>
<Card>
<CardHeader><CardTitle>Beträge</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Beträge</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<FormField control={form.control} name="taxRate" render={({ field }) => (
<FormItem className="grid grid-cols-2 items-center gap-4">
<FormLabel>MwSt.-Satz (%)</FormLabel>
<FormControl>
<Input type="number" min={0} step="0.5" className="max-w-[120px]" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl>
<FormMessage />
</FormItem>
)} />
<div className="space-y-1 rounded-lg bg-muted p-4 text-sm">
<FormField
control={form.control}
name="taxRate"
render={({ field }) => (
<FormItem className="grid grid-cols-2 items-center gap-4">
<FormLabel>MwSt.-Satz (%)</FormLabel>
<FormControl>
<Input
type="number"
min={0}
step="0.5"
className="max-w-[120px]"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="bg-muted space-y-1 rounded-lg p-4 text-sm">
<div className="flex justify-between">
<span>Zwischensumme (netto)</span>
<span className="font-medium">{formatCurrency(subtotal)}</span>
@@ -172,17 +309,32 @@ export function CreateInvoiceForm({ accountId, account }: Props) {
<span>{formatCurrency(total)}</span>
</div>
</div>
<FormField control={form.control} name="notes" render={({ field }) => (
<FormItem><FormLabel>Anmerkungen</FormLabel><FormControl>
<textarea {...field} className="flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Anmerkungen</FormLabel>
<FormControl>
<textarea
{...field}
className="border-input bg-background flex min-h-[60px] w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Rechnung erstellen'}</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird erstellt...' : 'Rechnung erstellen'}
</Button>
</div>
</form>
</Form>

View File

@@ -1,15 +1,23 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { createSepaBatch } from '../server/actions/finance-actions';
@@ -35,7 +43,9 @@ export function CreateSepaBatchForm({ accountId, account }: Props) {
accountId,
batchType: 'direct_debit' as const,
description: '',
executionDate: new Date(Date.now() + 7 * 86400000).toISOString().split('T')[0]!,
executionDate: new Date(Date.now() + 7 * 86400000)
.toISOString()
.split('T')[0]!,
painFormat: 'pain.008.003.02',
},
});
@@ -54,45 +64,74 @@ export function CreateSepaBatchForm({ accountId, account }: Props) {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6 max-w-2xl">
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="max-w-2xl space-y-6"
>
<Card>
<CardHeader>
<CardTitle>SEPA-Einzug erstellen</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField control={form.control} name="batchType" render={({ field }) => (
<FormItem>
<FormLabel>Typ</FormLabel>
<FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="direct_debit">Lastschrift (SEPA Core)</option>
<option value="credit_transfer">Überweisung</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField
control={form.control}
name="batchType"
render={({ field }) => (
<FormItem>
<FormLabel>Typ</FormLabel>
<FormControl>
<select
{...field}
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value="direct_debit">
Lastschrift (SEPA Core)
</option>
<option value="credit_transfer">Überweisung</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField control={form.control} name="description" render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung</FormLabel>
<FormControl><Input placeholder="z.B. Mitgliedsbeiträge Q1 2026" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung</FormLabel>
<FormControl>
<Input
placeholder="z.B. Mitgliedsbeiträge Q1 2026"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField control={form.control} name="executionDate" render={({ field }) => (
<FormItem>
<FormLabel>Ausführungsdatum *</FormLabel>
<FormControl><Input type="date" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField
control={form.control}
name="executionDate"
render={({ field }) => (
<FormItem>
<FormLabel>Ausführungsdatum *</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird erstellt...' : 'Einzug erstellen'}
</Button>

View File

@@ -1,8 +1,22 @@
import { z } from 'zod';
export const SepaBatchTypeEnum = z.enum(['direct_debit', 'credit_transfer']);
export const SepaBatchStatusEnum = z.enum(['draft', 'ready', 'submitted', 'executed', 'failed', 'cancelled']);
export const InvoiceStatusEnum = z.enum(['draft', 'sent', 'paid', 'overdue', 'cancelled', 'credited']);
export const SepaBatchStatusEnum = z.enum([
'draft',
'ready',
'submitted',
'executed',
'failed',
'cancelled',
]);
export const InvoiceStatusEnum = z.enum([
'draft',
'sent',
'paid',
'overdue',
'cancelled',
'credited',
]);
export const CreateSepaBatchSchema = z.object({
accountId: z.string().uuid(),
@@ -36,10 +50,14 @@ export const CreateInvoiceSchema = z.object({
dueDate: z.string(),
taxRate: z.number().min(0).default(0),
notes: z.string().optional(),
items: z.array(z.object({
description: z.string().min(1),
quantity: z.number().min(0.01).default(1),
unitPrice: z.number(),
})).min(1),
items: z
.array(
z.object({
description: z.string().min(1),
quantity: z.number().min(0.01).default(1),
unitPrice: z.number(),
}),
)
.min(1),
});
export type CreateInvoiceInput = z.infer<typeof CreateInvoiceSchema>;

View File

@@ -1,9 +1,11 @@
'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
CreateSepaBatchSchema,
AddSepaItemSchema,
@@ -81,13 +83,21 @@ export const createInvoice = authActionClient
// Gap 3: SEPA auto-populate from members
export const populateBatchFromMembers = authActionClient
.inputSchema(z.object({ batchId: z.string().uuid(), accountId: z.string().uuid() }))
.inputSchema(
z.object({ batchId: z.string().uuid(), accountId: z.string().uuid() }),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFinanceApi(client);
logger.info({ name: 'sepa.populate' }, 'Populating batch from members...');
const result = await api.populateBatchFromMembers(input.batchId, input.accountId);
logger.info({ name: 'sepa.populate', count: result.addedCount }, 'Populated');
const result = await api.populateBatchFromMembers(
input.batchId,
input.accountId,
);
logger.info(
{ name: 'sepa.populate', count: result.addedCount },
'Populated',
);
return { success: true, addedCount: result.addedCount };
});

View File

@@ -1,8 +1,17 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { CreateSepaBatchInput, AddSepaItemInput, CreateInvoiceInput } from '../schema/finance.schema';
import { generateDirectDebitXml, generateCreditTransferXml, validateIban } from './services/sepa-xml-generator.service';
import type { Database } from '@kit/supabase/database';
import type {
CreateSepaBatchInput,
AddSepaItemInput,
CreateInvoiceInput,
} from '../schema/finance.schema';
import {
generateDirectDebitXml,
generateCreditTransferXml,
validateIban,
} from './services/sepa-xml-generator.service';
/* eslint-disable @typescript-eslint/no-explicit-any */
@@ -12,24 +21,38 @@ export function createFinanceApi(client: SupabaseClient<Database>) {
return {
// --- SEPA Batches ---
async listBatches(accountId: string) {
const { data, error } = await client.from('sepa_batches').select('*')
.eq('account_id', accountId).order('created_at', { ascending: false });
const { data, error } = await client
.from('sepa_batches')
.select('*')
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (error) throw error;
return data ?? [];
},
async getBatch(batchId: string) {
const { data, error } = await client.from('sepa_batches').select('*').eq('id', batchId).single();
const { data, error } = await client
.from('sepa_batches')
.select('*')
.eq('id', batchId)
.single();
if (error) throw error;
return data;
},
async createBatch(input: CreateSepaBatchInput, userId: string) {
const { data, error } = await client.from('sepa_batches').insert({
account_id: input.accountId, batch_type: input.batchType,
description: input.description, execution_date: input.executionDate,
pain_format: input.painFormat, created_by: userId,
}).select().single();
const { data, error } = await client
.from('sepa_batches')
.insert({
account_id: input.accountId,
batch_type: input.batchType,
description: input.description,
execution_date: input.executionDate,
pain_format: input.painFormat,
created_by: userId,
})
.select()
.single();
if (error) throw error;
return data;
},
@@ -40,13 +63,21 @@ export function createFinanceApi(client: SupabaseClient<Database>) {
throw new Error(`Invalid IBAN: ${input.debtorIban}`);
}
const { data, error } = await client.from('sepa_items').insert({
batch_id: input.batchId, member_id: input.memberId,
debtor_name: input.debtorName, debtor_iban: input.debtorIban.replace(/\s/g, '').toUpperCase(),
debtor_bic: input.debtorBic, amount: input.amount,
mandate_id: input.mandateId, mandate_date: input.mandateDate,
remittance_info: input.remittanceInfo,
}).select().single();
const { data, error } = await client
.from('sepa_items')
.insert({
batch_id: input.batchId,
member_id: input.memberId,
debtor_name: input.debtorName,
debtor_iban: input.debtorIban.replace(/\s/g, '').toUpperCase(),
debtor_bic: input.debtorBic,
amount: input.amount,
mandate_id: input.mandateId,
mandate_date: input.mandateDate,
remittance_info: input.remittanceInfo,
})
.select()
.single();
if (error) throw error;
// Update batch totals
@@ -55,21 +86,34 @@ export function createFinanceApi(client: SupabaseClient<Database>) {
},
async getBatchItems(batchId: string) {
const { data, error } = await client.from('sepa_items').select('*')
.eq('batch_id', batchId).order('created_at');
const { data, error } = await client
.from('sepa_items')
.select('*')
.eq('batch_id', batchId)
.order('created_at');
if (error) throw error;
return data ?? [];
},
async recalculateBatchTotals(batchId: string) {
const items = await this.getBatchItems(batchId);
const total = items.reduce((sum: number, i: any) => sum + Number(i.amount), 0);
await client.from('sepa_batches').update({
total_amount: total, item_count: items.length,
}).eq('id', batchId);
const total = items.reduce(
(sum: number, i: any) => sum + Number(i.amount),
0,
);
await client
.from('sepa_batches')
.update({
total_amount: total,
item_count: items.length,
})
.eq('id', batchId);
},
async generateSepaXml(batchId: string, creditor: { name: string; iban: string; bic: string; creditorId: string }) {
async generateSepaXml(
batchId: string,
creditor: { name: string; iban: string; bic: string; creditorId: string },
) {
const batch = await this.getBatch(batchId);
const items = await this.getBatchItems(batchId);
@@ -89,39 +133,65 @@ export function createFinanceApi(client: SupabaseClient<Database>) {
})),
};
const xml = batch.batch_type === 'direct_debit'
? generateDirectDebitXml(config)
: generateCreditTransferXml(config);
const xml =
batch.batch_type === 'direct_debit'
? generateDirectDebitXml(config)
: generateCreditTransferXml(config);
// Update batch status
await client.from('sepa_batches').update({ status: 'ready' }).eq('id', batchId);
await client
.from('sepa_batches')
.update({ status: 'ready' })
.eq('id', batchId);
return xml;
},
// --- Invoices ---
async listInvoices(accountId: string, opts?: { status?: string }) {
let query = client.from('invoices').select('*').eq('account_id', accountId)
let query = client
.from('invoices')
.select('*')
.eq('account_id', accountId)
.order('issue_date', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status as Database['public']['Enums']['invoice_status']);
if (opts?.status)
query = query.eq(
'status',
opts.status as Database['public']['Enums']['invoice_status'],
);
const { data, error } = await query;
if (error) throw error;
return data ?? [];
},
async createInvoice(input: CreateInvoiceInput, userId: string) {
const subtotal = input.items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0);
const subtotal = input.items.reduce(
(sum, item) => sum + item.quantity * item.unitPrice,
0,
);
const taxAmount = subtotal * (input.taxRate / 100);
const totalAmount = subtotal + taxAmount;
const { data: invoice, error: invoiceError } = await client.from('invoices').insert({
account_id: input.accountId, invoice_number: input.invoiceNumber,
member_id: input.memberId, recipient_name: input.recipientName,
recipient_address: input.recipientAddress, issue_date: input.issueDate,
due_date: input.dueDate, status: 'draft',
subtotal, tax_rate: input.taxRate, tax_amount: taxAmount, total_amount: totalAmount,
notes: input.notes, created_by: userId,
}).select().single();
const { data: invoice, error: invoiceError } = await client
.from('invoices')
.insert({
account_id: input.accountId,
invoice_number: input.invoiceNumber,
member_id: input.memberId,
recipient_name: input.recipientName,
recipient_address: input.recipientAddress,
issue_date: input.issueDate,
due_date: input.dueDate,
status: 'draft',
subtotal,
tax_rate: input.taxRate,
tax_amount: taxAmount,
total_amount: totalAmount,
notes: input.notes,
created_by: userId,
})
.select()
.single();
if (invoiceError) throw invoiceError;
// Insert line items
@@ -134,17 +204,26 @@ export function createFinanceApi(client: SupabaseClient<Database>) {
sort_order: i,
}));
const { error: itemsError } = await client.from('invoice_items').insert(items);
const { error: itemsError } = await client
.from('invoice_items')
.insert(items);
if (itemsError) throw itemsError;
return invoice;
},
async getInvoiceWithItems(invoiceId: string) {
const { data, error } = await client.from('invoices').select('*').eq('id', invoiceId).single();
const { data, error } = await client
.from('invoices')
.select('*')
.eq('id', invoiceId)
.single();
if (error) throw error;
const { data: items } = await client.from('invoice_items').select('*')
.eq('invoice_id', invoiceId).order('sort_order');
const { data: items } = await client
.from('invoice_items')
.select('*')
.eq('invoice_id', invoiceId)
.order('sort_order');
return { ...data, items: items ?? [] };
},
@@ -172,15 +251,21 @@ export function createFinanceApi(client: SupabaseClient<Database>) {
.eq('account_id', accountId);
if (catError) throw catError;
const mandateMap = new Map((mandates ?? []).map((m: any) => [m.member_id, m]));
const categoryMap = new Map((categories ?? []).map((c: any) => [c.id, Number(c.amount)]));
const mandateMap = new Map(
(mandates ?? []).map((m: any) => [m.member_id, m]),
);
const categoryMap = new Map(
(categories ?? []).map((c: any) => [c.id, Number(c.amount)]),
);
let addedCount = 0;
for (const member of (members ?? []) as any[]) {
const mandate = mandateMap.get(member.id);
if (!mandate) continue;
const amount = member.dues_category_id ? categoryMap.get(member.dues_category_id) ?? 0 : 0;
const amount = member.dues_category_id
? (categoryMap.get(member.dues_category_id) ?? 0)
: 0;
if (amount <= 0) continue;
const { error } = await client.from('sepa_items').insert({

View File

@@ -210,7 +210,7 @@ export function validateIban(iban: string): boolean {
let numStr = '';
for (const char of rearranged) {
const code = char.charCodeAt(0);
numStr += (code >= 65 && code <= 90) ? (code - 55).toString() : char;
numStr += code >= 65 && code <= 90 ? (code - 55).toString() : char;
}
let remainder = 0;

View File

@@ -1,6 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "*.tsx", "src"],
"exclude": ["node_modules"]
}

View File

@@ -1,6 +1,7 @@
'use client';
import { useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Check } from 'lucide-react';
@@ -94,7 +95,7 @@ export function CatchBooksDataTable({
<select
value={currentYear}
onChange={handleYearChange}
className="flex h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
className="border-input bg-background flex h-9 rounded-md border px-3 py-1 text-sm shadow-sm"
>
<option value="">Alle Jahre</option>
{yearOptions.map((y) => (
@@ -107,7 +108,7 @@ export function CatchBooksDataTable({
<select
value={currentStatus}
onChange={handleStatusChange}
className="flex h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
className="border-input bg-background flex h-9 rounded-md border px-3 py-1 text-sm shadow-sm"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
@@ -126,8 +127,10 @@ export function CatchBooksDataTable({
<CardContent>
{data.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">Keine Fangbücher vorhanden</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
<h3 className="text-lg font-semibold">
Keine Fangbücher vorhanden
</h3>
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
Es wurden noch keine Fangbücher angelegt.
</p>
</div>
@@ -135,7 +138,7 @@ export function CatchBooksDataTable({
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Mitglied</th>
<th className="p-3 text-right font-medium">Jahr</th>
<th className="p-3 text-right font-medium">Angeltage</th>
@@ -145,7 +148,10 @@ export function CatchBooksDataTable({
</thead>
<tbody>
{data.map((cb) => {
const members = cb.members as Record<string, unknown> | null;
const members = cb.members as Record<
string,
unknown
> | null;
const memberName = members
? `${String(members.first_name ?? '')} ${String(members.last_name ?? '')}`.trim()
: String(cb.member_name ?? '—');
@@ -154,7 +160,7 @@ export function CatchBooksDataTable({
return (
<tr
key={String(cb.id)}
className="cursor-pointer border-b hover:bg-muted/30"
className="hover:bg-muted/30 cursor-pointer border-b"
onClick={() =>
router.push(
`/home/${account}/fischerei/catch-books/${String(cb.id)}`,
@@ -192,14 +198,24 @@ export function CatchBooksDataTable({
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Seite {page} von {totalPages} ({total} Einträge)
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => handlePageChange(page - 1)}>
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => handlePageChange(page - 1)}
>
Zurück
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => handlePageChange(page + 1)}>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
>
Weiter
</Button>
</div>

View File

@@ -1,11 +1,13 @@
'use client';
import { useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Plus } from 'lucide-react';
import { formatDate } from '@kit/shared/dates';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
@@ -69,11 +71,16 @@ export function CompetitionsDataTable({
<CardContent className="pt-6">
{data.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">Keine Wettbewerbe vorhanden</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
<h3 className="text-lg font-semibold">
Keine Wettbewerbe vorhanden
</h3>
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
Erstellen Sie Ihren ersten Wettbewerb.
</p>
<Link href={`/home/${account}/fischerei/competitions/new`} className="mt-4">
<Link
href={`/home/${account}/fischerei/competitions/new`}
className="mt-4"
>
<Button>Neuer Wettbewerb</Button>
</Link>
</div>
@@ -81,21 +88,26 @@ export function CompetitionsDataTable({
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
<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">Gewässer</th>
<th className="p-3 text-right font-medium">Max. Teilnehmer</th>
<th className="p-3 text-right font-medium">
Max. Teilnehmer
</th>
</tr>
</thead>
<tbody>
{data.map((comp) => {
const waters = comp.waters as Record<string, unknown> | null;
const waters = comp.waters as Record<
string,
unknown
> | null;
return (
<tr
key={String(comp.id)}
className="cursor-pointer border-b hover:bg-muted/30"
className="hover:bg-muted/30 cursor-pointer border-b"
onClick={() =>
router.push(
`/home/${account}/fischerei/competitions/${String(comp.id)}`,
@@ -104,9 +116,7 @@ export function CompetitionsDataTable({
>
<td className="p-3 font-medium">{String(comp.name)}</td>
<td className="p-3">
{comp.competition_date
? new Date(String(comp.competition_date)).toLocaleDateString('de-DE')
: '—'}
{formatDate(comp.competition_date as string | null)}
</td>
<td className="p-3">
{waters ? String(waters.name) : '—'}
@@ -126,14 +136,24 @@ export function CompetitionsDataTable({
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Seite {page} von {totalPages} ({total} Einträge)
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => handlePageChange(page - 1)}>
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => handlePageChange(page - 1)}
>
Zurück
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => handlePageChange(page + 1)}>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
>
Weiter
</Button>
</div>

View File

@@ -1,12 +1,13 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
@@ -15,7 +16,7 @@ import {
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { CreateFishSpeciesSchema } from '../schema/fischerei.schema';
@@ -43,12 +44,24 @@ export function CreateSpeciesForm({
nameLatin: (species?.name_latin as string) ?? '',
nameLocal: (species?.name_local as string) ?? '',
isActive: species?.is_active != null ? Boolean(species.is_active) : true,
protectedMinSizeCm: species?.protected_min_size_cm != null ? Number(species.protected_min_size_cm) : undefined,
protectedMinSizeCm:
species?.protected_min_size_cm != null
? Number(species.protected_min_size_cm)
: undefined,
protectionPeriodStart: (species?.protection_period_start as string) ?? '',
protectionPeriodEnd: (species?.protection_period_end as string) ?? '',
maxCatchPerDay: species?.max_catch_per_day != null ? Number(species.max_catch_per_day) : undefined,
maxCatchPerYear: species?.max_catch_per_year != null ? Number(species.max_catch_per_year) : undefined,
individualRecording: species?.individual_recording != null ? Boolean(species.individual_recording) : false,
maxCatchPerDay:
species?.max_catch_per_day != null
? Number(species.max_catch_per_day)
: undefined,
maxCatchPerYear:
species?.max_catch_per_year != null
? Number(species.max_catch_per_year)
: undefined,
individualRecording:
species?.individual_recording != null
? Boolean(species.individual_recording)
: false,
},
});
@@ -138,7 +151,9 @@ export function CreateSpeciesForm({
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(e.target.value ? Number(e.target.value) : undefined)
field.onChange(
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</FormControl>
@@ -194,7 +209,9 @@ export function CreateSpeciesForm({
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(e.target.value ? Number(e.target.value) : undefined)
field.onChange(
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</FormControl>
@@ -215,7 +232,9 @@ export function CreateSpeciesForm({
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(e.target.value ? Number(e.target.value) : undefined)
field.onChange(
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</FormControl>

View File

@@ -1,12 +1,14 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { todayISO } from '@kit/shared/dates';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
@@ -15,7 +17,7 @@ import {
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { CreateStockingSchema } from '../schema/fischerei.schema';
@@ -42,7 +44,7 @@ export function CreateStockingForm({
accountId,
waterId: '',
speciesId: '',
stockingDate: new Date().toISOString().split('T')[0],
stockingDate: todayISO(),
quantity: 0,
weightKg: undefined as number | undefined,
ageClass: 'sonstige' as const,
@@ -84,7 +86,7 @@ export function CreateStockingForm({
<FormControl>
<select
{...field}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value=""> Gewässer wählen </option>
{waters.map((w) => (
@@ -107,7 +109,7 @@ export function CreateStockingForm({
<FormControl>
<select
{...field}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value=""> Fischart wählen </option>
{species.map((s) => (
@@ -166,7 +168,9 @@ export function CreateStockingForm({
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(e.target.value ? Number(e.target.value) : undefined)
field.onChange(
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</FormControl>
@@ -183,7 +187,7 @@ export function CreateStockingForm({
<FormControl>
<select
{...field}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value="brut">Brut</option>
<option value="soemmerlinge">Sömmerlinge</option>
@@ -214,7 +218,9 @@ export function CreateStockingForm({
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(e.target.value ? Number(e.target.value) : undefined)
field.onChange(
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</FormControl>
@@ -232,7 +238,7 @@ export function CreateStockingForm({
<FormControl>
<textarea
{...field}
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />

View File

@@ -1,12 +1,13 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
@@ -15,7 +16,7 @@ import {
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { CreateWaterSchema } from '../schema/fischerei.schema';
@@ -43,18 +44,24 @@ export function CreateWaterForm({
shortName: (water?.short_name as string) ?? '',
waterType: (water?.water_type as string) ?? 'sonstige',
description: (water?.description as string) ?? '',
surfaceAreaHa: water?.surface_area_ha != null ? Number(water.surface_area_ha) : undefined,
surfaceAreaHa:
water?.surface_area_ha != null
? Number(water.surface_area_ha)
: undefined,
lengthM: water?.length_m != null ? Number(water.length_m) : undefined,
widthM: water?.width_m != null ? Number(water.width_m) : undefined,
avgDepthM: water?.avg_depth_m != null ? Number(water.avg_depth_m) : undefined,
maxDepthM: water?.max_depth_m != null ? Number(water.max_depth_m) : undefined,
avgDepthM:
water?.avg_depth_m != null ? Number(water.avg_depth_m) : undefined,
maxDepthM:
water?.max_depth_m != null ? Number(water.max_depth_m) : undefined,
outflow: (water?.outflow as string) ?? '',
location: (water?.location as string) ?? '',
county: (water?.county as string) ?? '',
geoLat: water?.geo_lat != null ? Number(water.geo_lat) : undefined,
geoLng: water?.geo_lng != null ? Number(water.geo_lng) : undefined,
lfvNumber: (water?.lfv_number as string) ?? '',
costShareDs: water?.cost_share_ds != null ? Number(water.cost_share_ds) : undefined,
costShareDs:
water?.cost_share_ds != null ? Number(water.cost_share_ds) : undefined,
},
});
@@ -117,7 +124,7 @@ export function CreateWaterForm({
<FormControl>
<select
{...field}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value="fluss">Fluss</option>
<option value="bach">Bach</option>
@@ -144,7 +151,7 @@ export function CreateWaterForm({
<FormControl>
<textarea
{...field}
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />

View File

@@ -2,15 +2,9 @@
import Link from 'next/link';
import {
Droplets,
Fish,
FileText,
BookOpen,
Trophy,
Euro,
} from 'lucide-react';
import { Droplets, Fish, FileText, BookOpen, Trophy, Euro } from 'lucide-react';
import { formatCurrencyAmount } from '@kit/shared/formatters';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
interface DashboardStats {
@@ -27,7 +21,10 @@ interface FischereiDashboardProps {
account: string;
}
export function FischereiDashboard({ stats, account }: FischereiDashboardProps) {
export function FischereiDashboard({
stats,
account,
}: FischereiDashboardProps) {
return (
<div className="flex w-full flex-col gap-6">
{/* Header */}
@@ -45,10 +42,12 @@ export function FischereiDashboard({ stats, account }: FischereiDashboardProps)
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Gewässer</p>
<p className="text-muted-foreground text-sm font-medium">
Gewässer
</p>
<p className="text-2xl font-bold">{stats.watersCount}</p>
</div>
<div className="rounded-full bg-primary/10 p-3 text-primary">
<div className="bg-primary/10 text-primary rounded-full p-3">
<Droplets className="h-5 w-5" />
</div>
</div>
@@ -61,10 +60,12 @@ export function FischereiDashboard({ stats, account }: FischereiDashboardProps)
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Fischarten</p>
<p className="text-muted-foreground text-sm font-medium">
Fischarten
</p>
<p className="text-2xl font-bold">{stats.speciesCount}</p>
</div>
<div className="rounded-full bg-primary/10 p-3 text-primary">
<div className="bg-primary/10 text-primary rounded-full p-3">
<Fish className="h-5 w-5" />
</div>
</div>
@@ -77,10 +78,14 @@ export function FischereiDashboard({ stats, account }: FischereiDashboardProps)
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Aktive Pachten</p>
<p className="text-2xl font-bold">{stats.activeLeasesCount}</p>
<p className="text-muted-foreground text-sm font-medium">
Aktive Pachten
</p>
<p className="text-2xl font-bold">
{stats.activeLeasesCount}
</p>
</div>
<div className="rounded-full bg-primary/10 p-3 text-primary">
<div className="bg-primary/10 text-primary rounded-full p-3">
<FileText className="h-5 w-5" />
</div>
</div>
@@ -93,10 +98,14 @@ export function FischereiDashboard({ stats, account }: FischereiDashboardProps)
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Offene Fangbücher</p>
<p className="text-2xl font-bold">{stats.pendingCatchBooksCount}</p>
<p className="text-muted-foreground text-sm font-medium">
Offene Fangbücher
</p>
<p className="text-2xl font-bold">
{stats.pendingCatchBooksCount}
</p>
</div>
<div className="rounded-full bg-primary/10 p-3 text-primary">
<div className="bg-primary/10 text-primary rounded-full p-3">
<BookOpen className="h-5 w-5" />
</div>
</div>
@@ -109,10 +118,14 @@ export function FischereiDashboard({ stats, account }: FischereiDashboardProps)
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Kommende Wettbewerbe</p>
<p className="text-2xl font-bold">{stats.upcomingCompetitionsCount}</p>
<p className="text-muted-foreground text-sm font-medium">
Kommende Wettbewerbe
</p>
<p className="text-2xl font-bold">
{stats.upcomingCompetitionsCount}
</p>
</div>
<div className="rounded-full bg-primary/10 p-3 text-primary">
<div className="bg-primary/10 text-primary rounded-full p-3">
<Trophy className="h-5 w-5" />
</div>
</div>
@@ -125,15 +138,14 @@ export function FischereiDashboard({ stats, account }: FischereiDashboardProps)
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Besatzkosten (lfd. Jahr)</p>
<p className="text-muted-foreground text-sm font-medium">
Besatzkosten (lfd. Jahr)
</p>
<p className="text-2xl font-bold">
{stats.stockingCostYtd.toLocaleString('de-DE', {
style: 'currency',
currency: 'EUR',
})}
{formatCurrencyAmount(stats.stockingCostYtd)}
</p>
</div>
<div className="rounded-full bg-primary/10 p-3 text-primary">
<div className="bg-primary/10 text-primary rounded-full p-3">
<Euro className="h-5 w-5" />
</div>
</div>
@@ -149,7 +161,7 @@ export function FischereiDashboard({ stats, account }: FischereiDashboardProps)
<CardTitle className="text-base">Letzte Besatzaktionen</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Noch keine Besatzaktionen vorhanden.
</p>
</CardContent>
@@ -160,7 +172,7 @@ export function FischereiDashboard({ stats, account }: FischereiDashboardProps)
<CardTitle className="text-base">Offene Fangbücher</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Keine Fangbücher zur Prüfung ausstehend.
</p>
</CardContent>

View File

@@ -2,6 +2,7 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@kit/ui/utils';
interface FischereiTabNavigationProps {
@@ -29,7 +30,10 @@ export function FischereiTabNavigation({
return (
<div className="mb-6 border-b">
<nav className="-mb-px flex space-x-1 overflow-x-auto" aria-label="Fischerei Navigation">
<nav
className="-mb-px flex space-x-1 overflow-x-auto"
aria-label="Fischerei Navigation"
>
{TABS.map((tab) => {
const isActive = tab.id === activeTab;
@@ -38,10 +42,10 @@ export function FischereiTabNavigation({
key={tab.id}
href={`${basePath}${tab.path}`}
className={cn(
'whitespace-nowrap border-b-2 px-4 py-2.5 text-sm font-medium transition-colors',
'border-b-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors',
isActive
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:border-muted-foreground/30 hover:text-foreground',
: 'text-muted-foreground hover:border-muted-foreground/30 hover:text-foreground border-transparent',
)}
>
{tab.label}

View File

@@ -1,11 +1,12 @@
'use client';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Plus } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
@@ -98,11 +99,16 @@ export function SpeciesDataTable({
<CardContent>
{data.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">Keine Fischarten vorhanden</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
<h3 className="text-lg font-semibold">
Keine Fischarten vorhanden
</h3>
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
Erstellen Sie Ihre erste Fischart.
</p>
<Link href={`/home/${account}/fischerei/species/new`} className="mt-4">
<Link
href={`/home/${account}/fischerei/species/new`}
className="mt-4"
>
<Button>Neue Fischart</Button>
</Link>
</div>
@@ -110,19 +116,28 @@ export function SpeciesDataTable({
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">Lat. Name</th>
<th className="p-3 text-right font-medium">Schonmaß (cm)</th>
<th className="p-3 text-right font-medium">
Schonmaß (cm)
</th>
<th className="p-3 text-left font-medium">Schonzeit</th>
<th className="p-3 text-right font-medium">Max. Fang/Tag</th>
<th className="p-3 text-right font-medium">
Max. Fang/Tag
</th>
</tr>
</thead>
<tbody>
{data.map((species) => (
<tr key={String(species.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-medium">{String(species.name)}</td>
<td className="p-3 italic text-muted-foreground">
<tr
key={String(species.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">
{String(species.name)}
</td>
<td className="text-muted-foreground p-3 italic">
{String(species.name_latin ?? '—')}
</td>
<td className="p-3 text-right">
@@ -131,7 +146,8 @@ export function SpeciesDataTable({
: '—'}
</td>
<td className="p-3">
{species.protection_period_start && species.protection_period_end
{species.protection_period_start &&
species.protection_period_end
? `${String(species.protection_period_start)} ${String(species.protection_period_end)}`
: '—'}
</td>
@@ -149,7 +165,7 @@ export function SpeciesDataTable({
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Seite {page} von {totalPages} ({total} Einträge)
</p>
<div className="flex gap-2">

View File

@@ -1,12 +1,15 @@
'use client';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Plus } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { formatDate } from '@kit/shared/dates';
import { formatNumber, formatCurrencyAmount } from '@kit/shared/formatters';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
@@ -74,11 +77,16 @@ export function StockingDataTable({
<CardContent className="pt-6">
{data.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">Keine Besatzeinträge vorhanden</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
<h3 className="text-lg font-semibold">
Keine Besatzeinträge vorhanden
</h3>
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
Tragen Sie den ersten Besatz ein.
</p>
<Link href={`/home/${account}/fischerei/stocking/new`} className="mt-4">
<Link
href={`/home/${account}/fischerei/stocking/new`}
className="mt-4"
>
<Button>Besatz eintragen</Button>
</Link>
</div>
@@ -86,7 +94,7 @@ export function StockingDataTable({
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Datum</th>
<th className="p-3 text-left font-medium">Gewässer</th>
<th className="p-3 text-left font-medium">Fischart</th>
@@ -99,33 +107,42 @@ export function StockingDataTable({
<tbody>
{data.map((row) => {
const waters = row.waters as Record<string, unknown> | null;
const species = row.fish_species as Record<string, unknown> | null;
const species = row.fish_species as Record<
string,
unknown
> | null;
return (
<tr key={String(row.id)} className="border-b hover:bg-muted/30">
<tr
key={String(row.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3">
{row.stocking_date
? new Date(String(row.stocking_date)).toLocaleDateString('de-DE')
: '—'}
{formatDate(row.stocking_date as string | null)}
</td>
<td className="p-3">
{waters ? String(waters.name) : '—'}
</td>
<td className="p-3">
{species ? String(species.name) : '—'}
</td>
<td className="p-3">{waters ? String(waters.name) : '—'}</td>
<td className="p-3">{species ? String(species.name) : '—'}</td>
<td className="p-3 text-right">
{Number(row.quantity).toLocaleString('de-DE')}
{formatNumber(row.quantity as number)}
</td>
<td className="p-3 text-right">
{row.weight_kg != null
? `${Number(row.weight_kg).toLocaleString('de-DE')} kg`
? `${formatNumber(row.weight_kg as number)} kg`
: '—'}
</td>
<td className="p-3">
<Badge variant="secondary">
{AGE_CLASS_LABELS[String(row.age_class)] ?? String(row.age_class)}
{AGE_CLASS_LABELS[String(row.age_class)] ??
String(row.age_class)}
</Badge>
</td>
<td className="p-3 text-right">
{row.cost_euros != null
? `${Number(row.cost_euros).toLocaleString('de-DE', { minimumFractionDigits: 2 })} €`
? formatCurrencyAmount(row.cost_euros as number)
: '—'}
</td>
</tr>
@@ -138,14 +155,24 @@ export function StockingDataTable({
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Seite {page} von {totalPages} ({total} Einträge)
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => handlePageChange(page - 1)}>
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => handlePageChange(page - 1)}
>
Zurück
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => handlePageChange(page + 1)}>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
>
Weiter
</Button>
</div>

View File

@@ -1,12 +1,14 @@
'use client';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Plus } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { formatNumber } from '@kit/shared/formatters';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
@@ -112,7 +114,7 @@ export function WatersDataTable({
<select
value={currentType}
onChange={handleTypeChange}
className="flex h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
className="border-input bg-background flex h-9 rounded-md border px-3 py-1 text-sm shadow-sm"
>
{WATER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
@@ -138,11 +140,16 @@ export function WatersDataTable({
<CardContent>
{data.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">Keine Gewässer vorhanden</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
<h3 className="text-lg font-semibold">
Keine Gewässer vorhanden
</h3>
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
Erstellen Sie Ihr erstes Gewässer, um loszulegen.
</p>
<Link href={`/home/${account}/fischerei/waters/new`} className="mt-4">
<Link
href={`/home/${account}/fischerei/waters/new`}
className="mt-4"
>
<Button>Neues Gewässer</Button>
</Link>
</div>
@@ -150,7 +157,7 @@ export function WatersDataTable({
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">Kurzname</th>
<th className="p-3 text-left font-medium">Typ</th>
@@ -162,7 +169,7 @@ export function WatersDataTable({
{data.map((water) => (
<tr
key={String(water.id)}
className="cursor-pointer border-b hover:bg-muted/30"
className="hover:bg-muted/30 cursor-pointer border-b"
onClick={() =>
router.push(
`/home/${account}/fischerei/waters/${String(water.id)}`,
@@ -177,7 +184,7 @@ export function WatersDataTable({
{String(water.name)}
</Link>
</td>
<td className="p-3 text-muted-foreground">
<td className="text-muted-foreground p-3">
{String(water.short_name ?? '—')}
</td>
<td className="p-3">
@@ -188,10 +195,10 @@ export function WatersDataTable({
</td>
<td className="p-3 text-right">
{water.surface_area_ha != null
? `${Number(water.surface_area_ha).toLocaleString('de-DE')} ha`
? `${formatNumber(water.surface_area_ha as number)} ha`
: '—'}
</td>
<td className="p-3 text-muted-foreground">
<td className="text-muted-foreground p-3">
{String(water.location ?? '—')}
</td>
</tr>
@@ -204,7 +211,7 @@ export function WatersDataTable({
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Seite {page} von {totalPages} ({total} Einträge)
</p>
<div className="flex gap-2">

View File

@@ -45,10 +45,7 @@ export function isInProtectionPeriod(
number,
number,
];
const [endMonth, endDay] = endMMDD.split('.').map(Number) as [
number,
number,
];
const [endMonth, endDay] = endMMDD.split('.').map(Number) as [number, number];
const currentValue = currentMonth * 100 + currentDay;
const startValue = startMonth * 100 + startDay;
@@ -100,8 +97,7 @@ export function computeLeaseAmountForYear(
let amount: number;
if (percentageIncrease > 0) {
amount =
initialAmount * Math.pow(1 + percentageIncrease / 100, yearOffset);
amount = initialAmount * Math.pow(1 + percentageIncrease / 100, yearOffset);
} else {
amount = initialAmount + fixedIncrease * yearOffset;
}

View File

@@ -1,5 +1,7 @@
import { z } from 'zod';
import { todayISO } from '@kit/shared/dates';
// =====================================================
// Enum Schemas
// =====================================================
@@ -51,17 +53,9 @@ export const leasePaymentMethodSchema = z.enum([
'ueberweisung',
]);
export const fishGenderSchema = z.enum([
'maennlich',
'weiblich',
'unbekannt',
]);
export const fishGenderSchema = z.enum(['maennlich', 'weiblich', 'unbekannt']);
export const fishSizeCategorySchema = z.enum([
'gross',
'mittel',
'klein',
]);
export const fishSizeCategorySchema = z.enum(['gross', 'mittel', 'klein']);
// =====================================================
// Type exports from enums
@@ -153,9 +147,11 @@ export const CreateFishSpeciesSchema = z.object({
individualRecording: z.boolean().default(false),
});
export const UpdateFishSpeciesSchema = CreateFishSpeciesSchema.partial().extend({
speciesId: z.string().uuid(),
});
export const UpdateFishSpeciesSchema = CreateFishSpeciesSchema.partial().extend(
{
speciesId: z.string().uuid(),
},
);
export type CreateFishSpeciesInput = z.infer<typeof CreateFishSpeciesSchema>;
export type UpdateFishSpeciesInput = z.infer<typeof UpdateFishSpeciesSchema>;
@@ -322,7 +318,7 @@ export const CreateInspectorAssignmentSchema = z.object({
accountId: z.string().uuid(),
waterId: z.string().uuid(),
memberId: z.string().uuid(),
assignmentStart: z.string().default(() => new Date().toISOString().split('T')[0]!),
assignmentStart: z.string().default(() => todayISO()),
assignmentEnd: z.string().optional(),
});
@@ -353,9 +349,11 @@ export const CreateCompetitionSchema = z.object({
resultCountCount: z.number().int().default(3),
});
export const UpdateCompetitionSchema = CreateCompetitionSchema.partial().extend({
competitionId: z.string().uuid(),
});
export const UpdateCompetitionSchema = CreateCompetitionSchema.partial().extend(
{
competitionId: z.string().uuid(),
},
);
export type CreateCompetitionInput = z.infer<typeof CreateCompetitionSchema>;
export type UpdateCompetitionInput = z.infer<typeof UpdateCompetitionSchema>;
@@ -427,4 +425,6 @@ export const CatchStatisticsFilterSchema = z.object({
});
export type FischereiExportInput = z.infer<typeof FischereiExportSchema>;
export type CatchStatisticsFilterInput = z.infer<typeof CatchStatisticsFilterSchema>;
export type CatchStatisticsFilterInput = z.infer<
typeof CatchStatisticsFilterSchema
>;

View File

@@ -1,7 +1,9 @@
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
@@ -30,7 +32,6 @@ import {
catchBookStatusSchema,
catchBookVerificationSchema,
} from '../../schema/fischerei.schema';
import { createFischereiApi } from '../api';
// =====================================================
@@ -197,9 +198,15 @@ export const createStocking = authActionClient
const api = createFischereiApi(client);
const userId = ctx.user.id;
logger.info({ name: 'fischerei.stocking.create' }, 'Creating stocking entry...');
logger.info(
{ name: 'fischerei.stocking.create' },
'Creating stocking entry...',
);
const result = await api.createStocking(input, userId);
logger.info({ name: 'fischerei.stocking.create' }, 'Stocking entry created');
logger.info(
{ name: 'fischerei.stocking.create' },
'Stocking entry created',
);
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
@@ -212,9 +219,15 @@ export const updateStocking = authActionClient
const api = createFischereiApi(client);
const userId = ctx.user.id;
logger.info({ name: 'fischerei.stocking.update' }, 'Updating stocking entry...');
logger.info(
{ name: 'fischerei.stocking.update' },
'Updating stocking entry...',
);
const result = await api.updateStocking(input, userId);
logger.info({ name: 'fischerei.stocking.update' }, 'Stocking entry updated');
logger.info(
{ name: 'fischerei.stocking.update' },
'Stocking entry updated',
);
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
@@ -231,9 +244,15 @@ export const deleteStocking = authActionClient
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info({ name: 'fischerei.stocking.delete' }, 'Deleting stocking entry...');
logger.info(
{ name: 'fischerei.stocking.delete' },
'Deleting stocking entry...',
);
await api.deleteStocking(input.stockingId);
logger.info({ name: 'fischerei.stocking.delete' }, 'Stocking entry deleted');
logger.info(
{ name: 'fischerei.stocking.delete' },
'Stocking entry deleted',
);
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true };
});
@@ -284,7 +303,10 @@ export const createCatchBook = authActionClient
const api = createFischereiApi(client);
const userId = ctx.user.id;
logger.info({ name: 'fischerei.catchBook.create' }, 'Creating catch book...');
logger.info(
{ name: 'fischerei.catchBook.create' },
'Creating catch book...',
);
const result = await api.createCatchBook(input, userId);
logger.info({ name: 'fischerei.catchBook.create' }, 'Catch book created');
revalidatePath('/home/[account]/fischerei', 'page');
@@ -299,7 +321,10 @@ export const updateCatchBook = authActionClient
const api = createFischereiApi(client);
const userId = ctx.user.id;
logger.info({ name: 'fischerei.catchBook.update' }, 'Updating catch book...');
logger.info(
{ name: 'fischerei.catchBook.update' },
'Updating catch book...',
);
const result = await api.updateCatchBook(input, userId);
logger.info({ name: 'fischerei.catchBook.update' }, 'Catch book updated');
revalidatePath('/home/[account]/fischerei', 'page');
@@ -319,7 +344,10 @@ export const submitCatchBook = authActionClient
const api = createFischereiApi(client);
const userId = ctx.user.id;
logger.info({ name: 'fischerei.catchBook.submit' }, 'Submitting catch book...');
logger.info(
{ name: 'fischerei.catchBook.submit' },
'Submitting catch book...',
);
const result = await api.submitCatchBook(input.catchBookId, userId);
logger.info({ name: 'fischerei.catchBook.submit' }, 'Catch book submitted');
revalidatePath('/home/[account]/fischerei', 'page');
@@ -456,7 +484,10 @@ export const assignInspector = authActionClient
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info({ name: 'fischerei.inspector.assign' }, 'Assigning inspector...');
logger.info(
{ name: 'fischerei.inspector.assign' },
'Assigning inspector...',
);
const result = await api.assignInspector(input);
logger.info({ name: 'fischerei.inspector.assign' }, 'Inspector assigned');
revalidatePath('/home/[account]/fischerei', 'page');
@@ -474,7 +505,10 @@ export const removeInspector = authActionClient
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info({ name: 'fischerei.inspector.remove' }, 'Removing inspector...');
logger.info(
{ name: 'fischerei.inspector.remove' },
'Removing inspector...',
);
await api.removeInspector(input.inspectorId);
logger.info({ name: 'fischerei.inspector.remove' }, 'Inspector removed');
revalidatePath('/home/[account]/fischerei', 'page');

View File

@@ -1,6 +1,7 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type {
CreateWaterInput,
UpdateWaterInput,
@@ -132,8 +133,10 @@ export function createFischereiApi(client: SupabaseClient<Database>) {
const updateData: Record<string, unknown> = { updated_by: userId };
if (input.name !== undefined) updateData.name = input.name;
if (input.shortName !== undefined) updateData.short_name = input.shortName;
if (input.waterType !== undefined) updateData.water_type = input.waterType;
if (input.shortName !== undefined)
updateData.short_name = input.shortName;
if (input.waterType !== undefined)
updateData.water_type = input.waterType;
if (input.description !== undefined)
updateData.description = input.description;
if (input.surfaceAreaHa !== undefined)
@@ -189,37 +192,42 @@ export function createFischereiApi(client: SupabaseClient<Database>) {
async getWaterDetail(waterId: string) {
// Fetch water + related data in parallel
const [waterResult, rulesResult, leasesResult, inspectorsResult, stockingResult] =
await Promise.all([
client.from('waters').select('*').eq('id', waterId).single(),
client
.from('water_species_rules')
.select(
'id, water_id, species_id, min_size_cm, protection_period_start, protection_period_end, max_catch_per_day, max_catch_per_year, created_at, fish_species ( id, name, name_latin )',
)
.eq('water_id', waterId),
client
.from('fishing_leases')
.select(
'id, lessor_name, start_date, end_date, initial_amount, fixed_annual_increase, percentage_annual_increase, payment_method, is_archived',
)
.eq('water_id', waterId)
.order('start_date', { ascending: false }),
client
.from('water_inspectors')
.select(
'id, water_id, member_id, assignment_start, assignment_end, created_at, members ( id, first_name, last_name )',
)
.eq('water_id', waterId),
client
.from('fish_stocking')
.select(
'id, stocking_date, quantity, weight_kg, age_class, cost_euros, remarks, fish_species ( id, name ), fish_suppliers ( id, name )',
)
.eq('water_id', waterId)
.order('stocking_date', { ascending: false })
.limit(20),
]);
const [
waterResult,
rulesResult,
leasesResult,
inspectorsResult,
stockingResult,
] = await Promise.all([
client.from('waters').select('*').eq('id', waterId).single(),
client
.from('water_species_rules')
.select(
'id, water_id, species_id, min_size_cm, protection_period_start, protection_period_end, max_catch_per_day, max_catch_per_year, created_at, fish_species ( id, name, name_latin )',
)
.eq('water_id', waterId),
client
.from('fishing_leases')
.select(
'id, lessor_name, start_date, end_date, initial_amount, fixed_annual_increase, percentage_annual_increase, payment_method, is_archived',
)
.eq('water_id', waterId)
.order('start_date', { ascending: false }),
client
.from('water_inspectors')
.select(
'id, water_id, member_id, assignment_start, assignment_end, created_at, members ( id, first_name, last_name )',
)
.eq('water_id', waterId),
client
.from('fish_stocking')
.select(
'id, stocking_date, quantity, weight_kg, age_class, cost_euros, remarks, fish_species ( id, name ), fish_suppliers ( id, name )',
)
.eq('water_id', waterId)
.order('stocking_date', { ascending: false })
.limit(20),
]);
if (waterResult.error) throw waterResult.error;
@@ -342,8 +350,7 @@ export function createFischereiApi(client: SupabaseClient<Database>) {
if (input.spawningSeasonEnd !== undefined)
updateData.spawning_season_end = input.spawningSeasonEnd;
if (input.hasSpecialSpawningSeason !== undefined)
updateData.has_special_spawning_season =
input.hasSpecialSpawningSeason;
updateData.has_special_spawning_season = input.hasSpecialSpawningSeason;
if (input.kFactorAvg !== undefined)
updateData.k_factor_avg = input.kFactorAvg;
if (input.kFactorMin !== undefined)
@@ -741,8 +748,7 @@ export function createFischereiApi(client: SupabaseClient<Database>) {
async updateCatchBook(input: UpdateCatchBookInput, userId: string) {
const updateData: Record<string, unknown> = { updated_by: userId };
if (input.memberId !== undefined)
updateData.member_id = input.memberId;
if (input.memberId !== undefined) updateData.member_id = input.memberId;
if (input.year !== undefined) updateData.year = input.year;
if (input.memberName !== undefined)
updateData.member_name = input.memberName;
@@ -782,7 +788,8 @@ export function createFischereiApi(client: SupabaseClient<Database>) {
const { data, error } = await client
.from('catch_books')
.update({
status: 'eingereicht' as Database['public']['Enums']['catch_book_status'],
status:
'eingereicht' as Database['public']['Enums']['catch_book_status'],
is_submitted: true,
submitted_at: new Date().toISOString(),
updated_by: userId,
@@ -872,10 +879,7 @@ export function createFischereiApi(client: SupabaseClient<Database>) {
return data;
},
async updateCatch(
catchId: string,
input: Partial<CreateCatchInput>,
) {
async updateCatch(catchId: string, input: Partial<CreateCatchInput>) {
const updateData: Record<string, unknown> = {};
if (input.speciesId !== undefined)
@@ -884,16 +888,14 @@ export function createFischereiApi(client: SupabaseClient<Database>) {
if (input.catchDate !== undefined)
updateData.catch_date = input.catchDate;
if (input.quantity !== undefined) updateData.quantity = input.quantity;
if (input.lengthCm !== undefined)
updateData.length_cm = input.lengthCm;
if (input.lengthCm !== undefined) updateData.length_cm = input.lengthCm;
if (input.weightG !== undefined) updateData.weight_g = input.weightG;
if (input.sizeCategory !== undefined)
updateData.size_category = input.sizeCategory;
if (input.gender !== undefined) updateData.gender = input.gender;
if (input.isEmptyEntry !== undefined)
updateData.is_empty_entry = input.isEmptyEntry;
if (input.hasError !== undefined)
updateData.has_error = input.hasError;
if (input.hasError !== undefined) updateData.has_error = input.hasError;
if (input.remarks !== undefined) updateData.remarks = input.remarks;
const { data, error } = await client
@@ -907,10 +909,7 @@ export function createFischereiApi(client: SupabaseClient<Database>) {
},
async deleteCatch(catchId: string) {
const { error } = await client
.from('catches')
.delete()
.eq('id', catchId);
const { error } = await client.from('catches').delete().eq('id', catchId);
if (error) throw error;
},
@@ -932,10 +931,7 @@ export function createFischereiApi(client: SupabaseClient<Database>) {
// Permits
// =====================================================
async listPermits(
accountId: string,
opts?: { archived?: boolean },
) {
async listPermits(accountId: string, opts?: { archived?: boolean }) {
let query = client
.from('fishing_permits')
.select(
@@ -1121,8 +1117,7 @@ export function createFischereiApi(client: SupabaseClient<Database>) {
if (input.competitionDate !== undefined)
updateData.competition_date = input.competitionDate;
if (input.eventId !== undefined) updateData.event_id = input.eventId;
if (input.permitId !== undefined)
updateData.permit_id = input.permitId;
if (input.permitId !== undefined) updateData.permit_id = input.permitId;
if (input.waterId !== undefined) updateData.water_id = input.waterId;
if (input.maxParticipants !== undefined)
updateData.max_participants = input.maxParticipants;
@@ -1363,8 +1358,7 @@ export function createFischereiApi(client: SupabaseClient<Database>) {
if (input.email !== undefined) updateData.email = input.email;
if (input.address !== undefined) updateData.address = input.address;
if (input.notes !== undefined) updateData.notes = input.notes;
if (input.isActive !== undefined)
updateData.is_active = input.isActive;
if (input.isActive !== undefined) updateData.is_active = input.isActive;
const { data, error } = await client
.from('fish_suppliers')

View File

@@ -1,6 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "*.tsx", "src"],
"exclude": ["node_modules"]
}

View File

@@ -1,17 +1,24 @@
'use client';
import { useCallback } from 'react';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { toast } from '@kit/ui/sonner';
import { useAction } from 'next-safe-action/hooks';
import { formatDate } from '@kit/shared/dates';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { toast } from '@kit/ui/sonner';
import { approveApplication, rejectApplication } from '../server/actions/member-actions';
import {
APPLICATION_STATUS_VARIANT,
APPLICATION_STATUS_LABEL,
} from '../lib/member-utils';
import {
approveApplication,
rejectApplication,
} from '../server/actions/member-actions';
interface ApplicationWorkflowProps {
applications: Array<Record<string, unknown>>;
@@ -58,11 +65,7 @@ export function ApplicationWorkflow({
const handleApprove = useCallback(
(applicationId: string) => {
if (
!window.confirm(
'Mitglied wird automatisch erstellt. Fortfahren?',
)
) {
if (!window.confirm('Mitglied wird automatisch erstellt. Fortfahren?')) {
return;
}
executeApprove({ applicationId, accountId });
@@ -91,7 +94,7 @@ export function ApplicationWorkflow({
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Aufnahmeanträge</h2>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
{applications.length} Antrag{applications.length !== 1 ? 'e' : ''}
</p>
</div>
@@ -99,7 +102,7 @@ export function ApplicationWorkflow({
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
<th className="px-4 py-3 text-left font-medium">Name</th>
<th className="px-4 py-3 text-left font-medium">E-Mail</th>
<th className="px-4 py-3 text-left font-medium">Datum</th>
@@ -112,7 +115,7 @@ export function ApplicationWorkflow({
<tr>
<td
colSpan={5}
className="px-4 py-8 text-center text-muted-foreground"
className="text-muted-foreground px-4 py-8 text-center"
>
Keine Aufnahmeanträge vorhanden.
</td>
@@ -130,18 +133,18 @@ export function ApplicationWorkflow({
{String(app.last_name ?? '')},{' '}
{String(app.first_name ?? '')}
</td>
<td className="px-4 py-3 text-muted-foreground">
<td className="text-muted-foreground px-4 py-3">
{String(app.email ?? '—')}
</td>
<td className="px-4 py-3 text-muted-foreground">
{app.created_at
? new Date(String(app.created_at)).toLocaleDateString(
'de-DE',
)
: '—'}
<td className="text-muted-foreground px-4 py-3">
{formatDate(app.created_at as string)}
</td>
<td className="px-4 py-3">
<Badge variant={APPLICATION_STATUS_VARIANT[appStatus] ?? 'secondary'}>
<Badge
variant={
APPLICATION_STATUS_VARIANT[appStatus] ?? 'secondary'
}
>
{APPLICATION_STATUS_LABEL[appStatus] ?? appStatus}
</Badge>
</td>

View File

@@ -1,14 +1,24 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { CreateMemberSchema } from '../schema/member.schema';
import { createMember } from '../server/actions/member-actions';
@@ -18,16 +28,34 @@ interface Props {
duesCategories: Array<{ id: string; name: string; amount: number }>;
}
export function CreateMemberForm({ accountId, account, duesCategories }: Props) {
export function CreateMemberForm({
accountId,
account,
duesCategories,
}: Props) {
const router = useRouter();
const form = useForm({
resolver: zodResolver(CreateMemberSchema),
defaultValues: {
accountId,
firstName: '', lastName: '', email: '', phone: '', mobile: '',
street: '', houseNumber: '', postalCode: '', city: '', country: 'DE',
memberNumber: '', status: 'active' as const, entryDate: new Date().toISOString().split('T')[0]!,
iban: '', bic: '', accountHolder: '', gdprConsent: false, notes: '',
firstName: '',
lastName: '',
email: '',
phone: '',
mobile: '',
street: '',
houseNumber: '',
postalCode: '',
city: '',
country: 'DE',
memberNumber: '',
status: 'active' as const,
entryDate: new Date().toISOString().split('T')[0]!,
iban: '',
bic: '',
accountHolder: '',
gdprConsent: false,
notes: '',
},
});
@@ -45,197 +73,517 @@ export function CreateMemberForm({ accountId, account, duesCategories }: Props)
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="space-y-6"
>
<Card>
<CardHeader><CardTitle>Persönliche Daten</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Persönliche Daten</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="firstName" render={({ field }) => (
<FormItem><FormLabel>Vorname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="lastName" render={({ field }) => (
<FormItem><FormLabel>Nachname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="dateOfBirth" render={({ field }) => (
<FormItem><FormLabel>Geburtsdatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="gender" render={({ field }) => (
<FormItem><FormLabel>Geschlecht</FormLabel><FormControl>
<select {...field} 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>
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>Vorname *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Nachname *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dateOfBirth"
render={({ field }) => (
<FormItem>
<FormLabel>Geburtsdatum</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="gender"
render={({ field }) => (
<FormItem>
<FormLabel>Geschlecht</FormLabel>
<FormControl>
<select
{...field}
className="border-input bg-background flex h-10 w-full rounded-md border 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>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Kontakt</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Kontakt</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="phone" render={({ field }) => (
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input type="tel" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="mobile" render={({ field }) => (
<FormItem><FormLabel>Mobil</FormLabel><FormControl><Input type="tel" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>E-Mail</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Telefon</FormLabel>
<FormControl>
<Input type="tel" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="mobile"
render={({ field }) => (
<FormItem>
<FormLabel>Mobil</FormLabel>
<FormControl>
<Input type="tel" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Adresse</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Adresse</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="street" render={({ field }) => (
<FormItem><FormLabel>Straße</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="houseNumber" render={({ field }) => (
<FormItem><FormLabel>Hausnummer</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="postalCode" render={({ field }) => (
<FormItem><FormLabel>PLZ</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="city" render={({ field }) => (
<FormItem><FormLabel>Ort</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="street"
render={({ field }) => (
<FormItem>
<FormLabel>Straße</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="houseNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Hausnummer</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="postalCode"
render={({ field }) => (
<FormItem>
<FormLabel>PLZ</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="city"
render={({ field }) => (
<FormItem>
<FormLabel>Ort</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Mitgliedschaft</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Mitgliedschaft</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="memberNumber" render={({ field }) => (
<FormItem><FormLabel>Mitgliedsnr.</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="status" render={({ field }) => (
<FormItem><FormLabel>Status</FormLabel><FormControl>
<select {...field} 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>
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="entryDate" render={({ field }) => (
<FormItem><FormLabel>Eintrittsdatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="memberNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Mitgliedsnr.</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<FormControl>
<select
{...field}
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
<option value="pending">Ausstehend</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="entryDate"
render={({ field }) => (
<FormItem>
<FormLabel>Eintrittsdatum</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{duesCategories.length > 0 && (
<FormField control={form.control} name="duesCategoryId" render={({ field }) => (
<FormItem><FormLabel>Beitragskategorie</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value=""> Keine </option>
{duesCategories.map(c => <option key={c.id} value={c.id}>{c.name} ({c.amount} )</option>)}
</select>
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="duesCategoryId"
render={({ field }) => (
<FormItem>
<FormLabel>Beitragskategorie</FormLabel>
<FormControl>
<select
{...field}
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value=""> Keine </option>
{duesCategories.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.amount} )
</option>
))}
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>SEPA-Bankdaten</CardTitle></CardHeader>
<CardHeader>
<CardTitle>SEPA-Bankdaten</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="iban" render={({ field }) => (
<FormItem><FormLabel>IBAN</FormLabel><FormControl><Input placeholder="DE89 3704 0044 0532 0130 00" {...field} onChange={(e) => field.onChange(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="bic" render={({ field }) => (
<FormItem><FormLabel>BIC</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="accountHolder" render={({ field }) => (
<FormItem><FormLabel>Kontoinhaber</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="iban"
render={({ field }) => (
<FormItem>
<FormLabel>IBAN</FormLabel>
<FormControl>
<Input
placeholder="DE89 3704 0044 0532 0130 00"
{...field}
onChange={(e) =>
field.onChange(
e.target.value
.toUpperCase()
.replace(/[^A-Z0-9]/g, ''),
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bic"
render={({ field }) => (
<FormItem>
<FormLabel>BIC</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accountHolder"
render={({ field }) => (
<FormItem>
<FormLabel>Kontoinhaber</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Guardian (Gap 4) */}
<Card>
<CardHeader><CardTitle>Erziehungsberechtigte (Jugend)</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Erziehungsberechtigte (Jugend)</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="guardianName" render={({ field }) => (
<FormItem><FormLabel>Name Erziehungsberechtigte/r</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="guardianPhone" render={({ field }) => (
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input type="tel" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="guardianEmail" render={({ field }) => (
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="guardianName"
render={({ field }) => (
<FormItem>
<FormLabel>Name Erziehungsberechtigte/r</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="guardianPhone"
render={({ field }) => (
<FormItem>
<FormLabel>Telefon</FormLabel>
<FormControl>
<Input type="tel" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="guardianEmail"
render={({ field }) => (
<FormItem>
<FormLabel>E-Mail</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Lifecycle flags (Gap 4) */}
<Card>
<CardHeader><CardTitle>Mitgliedschaftsmerkmale</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Mitgliedschaftsmerkmale</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{([
['isHonorary', 'Ehrenmitglied'],
['isFoundingMember', 'Gründungsmitglied'],
['isYouth', 'Jugendmitglied'],
['isRetiree', 'Rentner/Senior'],
['isProbationary', 'Probejahr'],
] as const).map(([name, label]) => (
<FormField key={name} control={form.control} name={name} render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl>
<input type="checkbox" checked={field.value as boolean} onChange={field.onChange} className="h-4 w-4 rounded border-input" />
</FormControl>
<FormLabel className="!mt-0">{label}</FormLabel>
</FormItem>
)} />
{(
[
['isHonorary', 'Ehrenmitglied'],
['isFoundingMember', 'Gründungsmitglied'],
['isYouth', 'Jugendmitglied'],
['isRetiree', 'Rentner/Senior'],
['isProbationary', 'Probejahr'],
] as const
).map(([name, label]) => (
<FormField
key={name}
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl>
<input
type="checkbox"
checked={field.value as boolean}
onChange={field.onChange}
className="border-input h-4 w-4 rounded"
/>
</FormControl>
<FormLabel className="!mt-0">{label}</FormLabel>
</FormItem>
)}
/>
))}
</CardContent>
</Card>
{/* GDPR granular (Gap 4) */}
<Card>
<CardHeader><CardTitle>Datenschutz-Einwilligungen</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Datenschutz-Einwilligungen</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{([
['gdprConsent', 'Allgemeine Einwilligung'],
['gdprNewsletter', 'Newsletter'],
['gdprInternet', 'Internet/Homepage'],
['gdprPrint', 'Vereinszeitung'],
['gdprBirthdayInfo', 'Geburtstagsinfo'],
] as const).map(([name, label]) => (
<FormField key={name} control={form.control} name={name} render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl>
<input type="checkbox" checked={field.value as boolean} onChange={field.onChange} className="h-4 w-4 rounded border-input" />
</FormControl>
<FormLabel className="!mt-0">{label}</FormLabel>
</FormItem>
)} />
{(
[
['gdprConsent', 'Allgemeine Einwilligung'],
['gdprNewsletter', 'Newsletter'],
['gdprInternet', 'Internet/Homepage'],
['gdprPrint', 'Vereinszeitung'],
['gdprBirthdayInfo', 'Geburtstagsinfo'],
] as const
).map(([name, label]) => (
<FormField
key={name}
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl>
<input
type="checkbox"
checked={field.value as boolean}
onChange={field.onChange}
className="border-input h-4 w-4 rounded"
/>
</FormControl>
<FormLabel className="!mt-0">{label}</FormLabel>
</FormItem>
)}
/>
))}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Sonstiges</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Sonstiges</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="salutation" render={({ field }) => (
<FormItem><FormLabel>Anrede</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value=""> Keine </option>
<option value="Herr">Herr</option>
<option value="Frau">Frau</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="birthplace" render={({ field }) => (
<FormItem><FormLabel>Geburtsort</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="street2" render={({ field }) => (
<FormItem><FormLabel>Adresszusatz</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="salutation"
render={({ field }) => (
<FormItem>
<FormLabel>Anrede</FormLabel>
<FormControl>
<select
{...field}
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value=""> Keine </option>
<option value="Herr">Herr</option>
<option value="Frau">Frau</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="birthplace"
render={({ field }) => (
<FormItem>
<FormLabel>Geburtsort</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="street2"
render={({ field }) => (
<FormItem>
<FormLabel>Adresszusatz</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField control={form.control} name="notes" render={({ field }) => (
<FormItem><FormLabel>Notizen</FormLabel><FormControl><textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notizen</FormLabel>
<FormControl>
<textarea
{...field}
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Mitglied erstellen'}</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird erstellt...' : 'Mitglied erstellen'}
</Button>
</div>
</form>
</Form>

View File

@@ -1,13 +1,17 @@
'use client';
import { useState, useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { toast } from '@kit/ui/sonner';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { formatCurrencyAmount } from '@kit/shared/formatters';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import {
createDuesCategory,
@@ -90,7 +94,11 @@ export function DuesCategoryManager({
name: values.name,
description: values.description,
amount: Number(values.amount),
interval: values.interval as 'monthly' | 'quarterly' | 'half_yearly' | 'yearly',
interval: values.interval as
| 'monthly'
| 'quarterly'
| 'half_yearly'
| 'yearly',
isDefault: values.isDefault,
});
},
@@ -100,9 +108,7 @@ export function DuesCategoryManager({
const handleDelete = useCallback(
(categoryId: string, categoryName: string) => {
if (
!window.confirm(
`Beitragskategorie "${categoryName}" wirklich löschen?`,
)
!window.confirm(`Beitragskategorie "${categoryName}" wirklich löschen?`)
) {
return;
}
@@ -158,7 +164,7 @@ export function DuesCategoryManager({
<div className="space-y-1">
<label className="text-sm font-medium">Intervall</label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
{...form.register('interval')}
>
<option value="monthly">Monatlich</option>
@@ -171,7 +177,7 @@ export function DuesCategoryManager({
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
className="rounded border-input"
className="border-input rounded"
{...form.register('isDefault')}
/>
Standard
@@ -191,7 +197,7 @@ export function DuesCategoryManager({
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
<th className="px-4 py-3 text-left font-medium">Name</th>
<th className="px-4 py-3 text-left font-medium">Beschreibung</th>
<th className="px-4 py-3 text-right font-medium">Betrag</th>
@@ -205,7 +211,7 @@ export function DuesCategoryManager({
<tr>
<td
colSpan={6}
className="px-4 py-8 text-center text-muted-foreground"
className="text-muted-foreground px-4 py-8 text-center"
>
Keine Beitragskategorien vorhanden.
</td>
@@ -221,14 +227,11 @@ export function DuesCategoryManager({
return (
<tr key={catId} className="border-b">
<td className="px-4 py-3 font-medium">{catName}</td>
<td className="px-4 py-3 text-muted-foreground">
<td className="text-muted-foreground px-4 py-3">
{String(cat.description ?? '—')}
</td>
<td className="px-4 py-3 text-right font-mono">
{amount.toLocaleString('de-DE', {
style: 'currency',
currency: 'EUR',
})}
{formatCurrencyAmount(amount)}
</td>
<td className="px-4 py-3">
{INTERVAL_LABELS[interval] ?? interval}

View File

@@ -1,14 +1,22 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { UpdateMemberSchema } from '../schema/member.schema';
@@ -78,131 +86,382 @@ export function EditMemberForm({ member, account, accountId }: Props) {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="space-y-6"
>
<Card>
<CardHeader><CardTitle>Persönliche Daten</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Persönliche Daten</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="salutation" render={({ field }) => (
<FormItem><FormLabel>Anrede</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value=""></option><option value="Herr">Herr</option><option value="Frau">Frau</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="salutation"
render={({ field }) => (
<FormItem>
<FormLabel>Anrede</FormLabel>
<FormControl>
<select
{...field}
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value=""></option>
<option value="Herr">Herr</option>
<option value="Frau">Frau</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div />
<FormField control={form.control} name="firstName" render={({ field }) => (
<FormItem><FormLabel>Vorname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="lastName" render={({ field }) => (
<FormItem><FormLabel>Nachname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="memberNumber" render={({ field }) => (
<FormItem><FormLabel>Mitgliedsnr.</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="birthplace" render={({ field }) => (
<FormItem><FormLabel>Geburtsort</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>Vorname *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Nachname *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="memberNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Mitgliedsnr.</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="birthplace"
render={({ field }) => (
<FormItem>
<FormLabel>Geburtsort</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Kontakt</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Kontakt</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="phone" render={({ field }) => (
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="mobile" render={({ field }) => (
<FormItem><FormLabel>Mobil</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>E-Mail</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Telefon</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="mobile"
render={({ field }) => (
<FormItem>
<FormLabel>Mobil</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Adresse</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Adresse</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="street" render={({ field }) => (
<FormItem><FormLabel>Straße</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="houseNumber" render={({ field }) => (
<FormItem><FormLabel>Hausnummer</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="street2" render={({ field }) => (
<FormItem><FormLabel>Adresszusatz</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="street"
render={({ field }) => (
<FormItem>
<FormLabel>Straße</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="houseNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Hausnummer</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="street2"
render={({ field }) => (
<FormItem>
<FormLabel>Adresszusatz</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div />
<FormField control={form.control} name="postalCode" render={({ field }) => (
<FormItem><FormLabel>PLZ</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="city" render={({ field }) => (
<FormItem><FormLabel>Ort</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="postalCode"
render={({ field }) => (
<FormItem>
<FormLabel>PLZ</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="city"
render={({ field }) => (
<FormItem>
<FormLabel>Ort</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>SEPA-Bankdaten</CardTitle></CardHeader>
<CardHeader>
<CardTitle>SEPA-Bankdaten</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="iban" render={({ field }) => (
<FormItem><FormLabel>IBAN</FormLabel><FormControl><Input {...field} onChange={(e) => field.onChange(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="bic" render={({ field }) => (
<FormItem><FormLabel>BIC</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="accountHolder" render={({ field }) => (
<FormItem><FormLabel>Kontoinhaber</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="iban"
render={({ field }) => (
<FormItem>
<FormLabel>IBAN</FormLabel>
<FormControl>
<Input
{...field}
onChange={(e) =>
field.onChange(
e.target.value
.toUpperCase()
.replace(/[^A-Z0-9]/g, ''),
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bic"
render={({ field }) => (
<FormItem>
<FormLabel>BIC</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accountHolder"
render={({ field }) => (
<FormItem>
<FormLabel>Kontoinhaber</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Erziehungsberechtigte</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Erziehungsberechtigte</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="guardianName" render={({ field }) => (
<FormItem><FormLabel>Name</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="guardianPhone" render={({ field }) => (
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="guardianEmail" render={({ field }) => (
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="guardianName"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="guardianPhone"
render={({ field }) => (
<FormItem>
<FormLabel>Telefon</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="guardianEmail"
render={({ field }) => (
<FormItem>
<FormLabel>E-Mail</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Merkmale & Datenschutz</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Merkmale & Datenschutz</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{([
['isHonorary', 'Ehrenmitglied'], ['isFoundingMember', 'Gründungsmitglied'],
['isYouth', 'Jugend'], ['isRetiree', 'Rentner'],
['isProbationary', 'Probejahr'],
] as const).map(([name, label]) => (
<FormField key={name} control={form.control} name={name} render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl><input type="checkbox" checked={field.value as boolean} onChange={field.onChange} className="h-4 w-4" /></FormControl>
<FormLabel className="!mt-0 text-sm">{label}</FormLabel>
</FormItem>
)} />
{(
[
['isHonorary', 'Ehrenmitglied'],
['isFoundingMember', 'Gründungsmitglied'],
['isYouth', 'Jugend'],
['isRetiree', 'Rentner'],
['isProbationary', 'Probejahr'],
] as const
).map(([name, label]) => (
<FormField
key={name}
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl>
<input
type="checkbox"
checked={field.value as boolean}
onChange={field.onChange}
className="h-4 w-4"
/>
</FormControl>
<FormLabel className="!mt-0 text-sm">{label}</FormLabel>
</FormItem>
)}
/>
))}
</div>
<div className="border-t pt-3">
<p className="text-xs font-medium text-muted-foreground mb-2">DSGVO-Einwilligungen</p>
<p className="text-muted-foreground mb-2 text-xs font-medium">
DSGVO-Einwilligungen
</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{([
['gdprConsent', 'Allgemein'], ['gdprNewsletter', 'Newsletter'],
['gdprInternet', 'Internet'], ['gdprPrint', 'Zeitung'],
['gdprBirthdayInfo', 'Geburtstag'],
] as const).map(([name, label]) => (
<FormField key={name} control={form.control} name={name} render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl><input type="checkbox" checked={field.value as boolean} onChange={field.onChange} className="h-4 w-4" /></FormControl>
<FormLabel className="!mt-0 text-sm">{label}</FormLabel>
</FormItem>
)} />
{(
[
['gdprConsent', 'Allgemein'],
['gdprNewsletter', 'Newsletter'],
['gdprInternet', 'Internet'],
['gdprPrint', 'Zeitung'],
['gdprBirthdayInfo', 'Geburtstag'],
] as const
).map(([name, label]) => (
<FormField
key={name}
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl>
<input
type="checkbox"
checked={field.value as boolean}
onChange={field.onChange}
className="h-4 w-4"
/>
</FormControl>
<FormLabel className="!mt-0 text-sm">{label}</FormLabel>
</FormItem>
)}
/>
))}
</div>
</div>
@@ -210,17 +469,36 @@ export function EditMemberForm({ member, account, accountId }: Props) {
</Card>
<Card>
<CardHeader><CardTitle>Notizen</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Notizen</CardTitle>
</CardHeader>
<CardContent>
<FormField control={form.control} name="notes" render={({ field }) => (
<FormItem><FormControl><textarea {...field} rows={4} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormControl>
<textarea
{...field}
rows={4}
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird gespeichert...' : 'Änderungen speichern'}</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird gespeichert...' : 'Änderungen speichern'}
</Button>
</div>
</form>
</Form>

View File

@@ -1,14 +1,18 @@
'use client';
import { useState, useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { toast } from '@kit/ui/sonner';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { formatDate } from '@kit/shared/dates';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { formatIban } from '../lib/member-utils';
import { createMandate, revokeMandate } from '../server/actions/member-actions';
@@ -119,7 +123,7 @@ export function MandateManager({
bic: values.bic,
accountHolder: values.accountHolder,
mandateDate: values.mandateDate,
sequence: values.sequence as "FRST" | "RCUR" | "FNAL" | "OOFF",
sequence: values.sequence as 'FRST' | 'RCUR' | 'FNAL' | 'OOFF',
});
},
[executeCreate, memberId, accountId],
@@ -127,11 +131,7 @@ export function MandateManager({
const handleRevoke = useCallback(
(mandateId: string, reference: string) => {
if (
!window.confirm(
`Mandat "${reference}" wirklich widerrufen?`,
)
) {
if (!window.confirm(`Mandat "${reference}" wirklich widerrufen?`)) {
return;
}
executeRevoke({ mandateId });
@@ -185,10 +185,7 @@ export function MandateManager({
</div>
<div className="space-y-1">
<label className="text-sm font-medium">BIC</label>
<Input
placeholder="COBADEFFXXX"
{...form.register('bic')}
/>
<Input placeholder="COBADEFFXXX" {...form.register('bic')} />
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Kontoinhaber *</label>
@@ -207,7 +204,7 @@ export function MandateManager({
<div className="space-y-1">
<label className="text-sm font-medium">Sequenz</label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
{...form.register('sequence')}
>
<option value="FRST">FRST Erstlastschrift</option>
@@ -230,7 +227,7 @@ export function MandateManager({
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
<th className="px-4 py-3 text-left font-medium">Referenz</th>
<th className="px-4 py-3 text-left font-medium">IBAN</th>
<th className="px-4 py-3 text-left font-medium">Kontoinhaber</th>
@@ -245,7 +242,7 @@ export function MandateManager({
<tr>
<td
colSpan={7}
className="px-4 py-8 text-center text-muted-foreground"
className="text-muted-foreground px-4 py-8 text-center"
>
Keine SEPA-Mandate vorhanden.
</td>
@@ -261,21 +258,15 @@ export function MandateManager({
return (
<tr key={mandateId} className="border-b">
<td className="px-4 py-3 font-mono text-xs">
{reference}
</td>
<td className="px-4 py-3 font-mono text-xs">{reference}</td>
<td className="px-4 py-3 font-mono text-xs">
{formatIban(mandate.iban as string | null | undefined)}
</td>
<td className="px-4 py-3">
{String(mandate.account_holder ?? '—')}
</td>
<td className="px-4 py-3 text-muted-foreground">
{mandate.mandate_date
? new Date(
String(mandate.mandate_date),
).toLocaleDateString('de-DE')
: '—'}
<td className="text-muted-foreground px-4 py-3">
{formatDate(mandate.mandate_date as string)}
</td>
<td className="px-4 py-3">
<Badge variant={getMandateStatusColor(mandateStatus)}>

View File

@@ -1,13 +1,17 @@
'use client';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { toast } from '@kit/ui/sonner';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { formatDate } from '@kit/shared/dates';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import {
STATUS_LABELS,
@@ -25,16 +29,26 @@ interface MemberDetailViewProps {
accountId: string;
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
function DetailRow({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-4 border-b py-2 last:border-b-0">
<span className="text-sm font-medium text-muted-foreground">{label}</span>
<span className="text-sm text-right">{value ?? '—'}</span>
<span className="text-muted-foreground text-sm font-medium">{label}</span>
<span className="text-right text-sm">{value ?? '—'}</span>
</div>
);
}
export function MemberDetailView({ member, account, accountId }: MemberDetailViewProps) {
export function MemberDetailView({
member,
account,
accountId,
}: MemberDetailViewProps) {
const router = useRouter();
const memberId = String(member.id ?? '');
@@ -45,32 +59,42 @@ export function MemberDetailView({ member, account, accountId }: MemberDetailVie
const form = useForm();
const { execute: executeDelete, isPending: isDeleting } = useAction(deleteMember, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Mitglied wurde gekündigt');
router.push(`/home/${account}/members-cms`);
}
const { execute: executeDelete, isPending: isDeleting } = useAction(
deleteMember,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Mitglied wurde gekündigt');
router.push(`/home/${account}/members-cms`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Kündigen');
},
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Kündigen');
},
});
);
const { execute: executeUpdate, isPending: isUpdating } = useAction(updateMember, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Mitglied wurde archiviert');
router.refresh();
}
const { execute: executeUpdate, isPending: isUpdating } = useAction(
updateMember,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Mitglied wurde archiviert');
router.refresh();
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Archivieren');
},
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Archivieren');
},
});
);
const handleDelete = useCallback(() => {
if (!window.confirm(`Möchten Sie ${fullName} wirklich kündigen? Diese Aktion kann nicht rückgängig gemacht werden.`)) {
if (
!window.confirm(
`Möchten Sie ${fullName} wirklich kündigen? Diese Aktion kann nicht rückgängig gemacht werden.`,
)
) {
return;
}
executeDelete({ memberId, accountId });
@@ -88,7 +112,9 @@ export function MemberDetailView({ member, account, accountId }: MemberDetailVie
}, [executeUpdate, memberId, accountId, fullName]);
const age = computeAge(member.date_of_birth as string | null | undefined);
const membershipYears = computeMembershipYears(member.entry_date as string | null | undefined);
const membershipYears = computeMembershipYears(
member.entry_date as string | null | undefined,
);
const address = formatAddress(member);
const iban = formatIban(member.iban as string | null | undefined);
@@ -103,7 +129,7 @@ export function MemberDetailView({ member, account, accountId }: MemberDetailVie
{STATUS_LABELS[status] ?? status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Mitgliedsnr. {String(member.member_number ?? '—')}
</p>
</div>
@@ -147,12 +173,18 @@ export function MemberDetailView({ member, account, accountId }: MemberDetailVie
label="Geburtsdatum"
value={
member.date_of_birth
? `${new Date(String(member.date_of_birth)).toLocaleDateString('de-DE')}${age !== null ? ` (${age} Jahre)` : ''}`
? `${formatDate(member.date_of_birth as string)}${age !== null ? ` (${age} Jahre)` : ''}`
: null
}
/>
<DetailRow label="Geschlecht" value={String(member.gender ?? '—')} />
<DetailRow label="Anrede" value={String(member.salutation ?? '—')} />
<DetailRow
label="Geschlecht"
value={String(member.gender ?? '—')}
/>
<DetailRow
label="Anrede"
value={String(member.salutation ?? '—')}
/>
</CardContent>
</Card>
@@ -187,7 +219,10 @@ export function MemberDetailView({ member, account, accountId }: MemberDetailVie
<CardTitle>Mitgliedschaft</CardTitle>
</CardHeader>
<CardContent>
<DetailRow label="Mitgliedsnr." value={String(member.member_number ?? '—')} />
<DetailRow
label="Mitgliedsnr."
value={String(member.member_number ?? '—')}
/>
<DetailRow
label="Status"
value={
@@ -198,11 +233,7 @@ export function MemberDetailView({ member, account, accountId }: MemberDetailVie
/>
<DetailRow
label="Eintrittsdatum"
value={
member.entry_date
? new Date(String(member.entry_date)).toLocaleDateString('de-DE')
: '—'
}
value={formatDate(member.entry_date as string)}
/>
<DetailRow
label="Mitgliedsjahre"
@@ -210,7 +241,10 @@ export function MemberDetailView({ member, account, accountId }: MemberDetailVie
/>
<DetailRow label="IBAN" value={iban} />
<DetailRow label="BIC" value={String(member.bic ?? '—')} />
<DetailRow label="Kontoinhaber" value={String(member.account_holder ?? '—')} />
<DetailRow
label="Kontoinhaber"
value={String(member.account_holder ?? '—')}
/>
<DetailRow label="Notizen" value={String(member.notes ?? '—')} />
</CardContent>
</Card>

View File

@@ -1,15 +1,23 @@
'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import {
Upload,
ArrowRight,
ArrowLeft,
CheckCircle,
AlertTriangle,
} from 'lucide-react';
import { useAction } from 'next-safe-action/hooks';
import Papa from 'papaparse';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Badge } from '@kit/ui/badge';
import { toast } from '@kit/ui/sonner';
import { useAction } from 'next-safe-action/hooks';
import { Upload, ArrowRight, ArrowLeft, CheckCircle, AlertTriangle } from 'lucide-react';
import { createMember } from '../server/actions/member-actions';
@@ -46,45 +54,56 @@ export function MemberImportWizard({ accountId, account }: Props) {
const [rawData, setRawData] = useState<string[][]>([]);
const [headers, setHeaders] = useState<string[]>([]);
const [mapping, setMapping] = useState<Record<string, string>>({});
const [importResults, setImportResults] = useState<{ success: number; errors: string[] }>({ success: 0, errors: [] });
const [importResults, setImportResults] = useState<{
success: number;
errors: string[];
}>({ success: 0, errors: [] });
const { execute: executeCreate } = useAction(createMember);
// Step 1: Parse file
const handleFileUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const handleFileUpload = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
Papa.parse(file, {
delimiter: ';',
encoding: 'UTF-8',
complete: (result) => {
const data = result.data as string[][];
if (data.length < 2) {
toast.error('Datei enthält keine Daten');
return;
}
setHeaders(data[0]!);
setRawData(data.slice(1).filter(row => row.some(cell => cell?.trim())));
// Auto-map by header name similarity
const autoMap: Record<string, string> = {};
for (const field of MEMBER_FIELDS) {
const match = data[0]!.findIndex(h =>
h.toLowerCase().includes(field.label.toLowerCase().replace('.', '')) ||
h.toLowerCase().includes(field.key.toLowerCase())
Papa.parse(file, {
delimiter: ';',
encoding: 'UTF-8',
complete: (result) => {
const data = result.data as string[][];
if (data.length < 2) {
toast.error('Datei enthält keine Daten');
return;
}
setHeaders(data[0]!);
setRawData(
data.slice(1).filter((row) => row.some((cell) => cell?.trim())),
);
if (match >= 0) autoMap[field.key] = String(match);
}
setMapping(autoMap);
setStep('mapping');
toast.success(`${data.length - 1} Zeilen erkannt`);
},
error: (err) => {
toast.error(`Fehler beim Lesen: ${err.message}`);
},
});
}, []);
// Auto-map by header name similarity
const autoMap: Record<string, string> = {};
for (const field of MEMBER_FIELDS) {
const match = data[0]!.findIndex(
(h) =>
h
.toLowerCase()
.includes(field.label.toLowerCase().replace('.', '')) ||
h.toLowerCase().includes(field.key.toLowerCase()),
);
if (match >= 0) autoMap[field.key] = String(match);
}
setMapping(autoMap);
setStep('mapping');
toast.success(`${data.length - 1} Zeilen erkannt`);
},
error: (err) => {
toast.error(`Fehler beim Lesen: ${err.message}`);
},
});
},
[],
);
// Step 3: Execute import
const executeImport = useCallback(async () => {
@@ -111,7 +130,9 @@ export function MemberImportWizard({ accountId, account }: Props) {
await executeCreate(memberData as any);
success++;
} catch (err) {
errors.push(`Zeile ${i + 2}: ${err instanceof Error ? err.message : 'Unbekannter Fehler'}`);
errors.push(
`Zeile ${i + 2}: ${err instanceof Error ? err.message : 'Unbekannter Fehler'}`,
);
}
}
@@ -131,15 +152,35 @@ export function MemberImportWizard({ accountId, account }: Props) {
{/* Step indicator */}
<div className="flex items-center justify-center gap-2">
{(['upload', 'mapping', 'preview', 'done'] as const).map((s, i) => {
const labels = ['Datei hochladen', 'Spalten zuordnen', 'Vorschau & Import', 'Fertig'];
const isActive = ['upload', 'mapping', 'preview', 'importing', 'done'].indexOf(step) >= i;
const labels = [
'Datei hochladen',
'Spalten zuordnen',
'Vorschau & Import',
'Fertig',
];
const isActive =
['upload', 'mapping', 'preview', 'importing', 'done'].indexOf(
step,
) >= i;
return (
<div key={s} className="flex items-center gap-2">
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold ${
isActive ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
}`}>{i + 1}</div>
<span className={`text-sm ${isActive ? 'font-semibold' : 'text-muted-foreground'}`}>{labels[i]}</span>
{i < 3 && <ArrowRight className="h-4 w-4 text-muted-foreground" />}
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold ${
isActive
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
{i + 1}
</div>
<span
className={`text-sm ${isActive ? 'font-semibold' : 'text-muted-foreground'}`}
>
{labels[i]}
</span>
{i < 3 && (
<ArrowRight className="text-muted-foreground h-4 w-4" />
)}
</div>
);
})}
@@ -148,14 +189,25 @@ export function MemberImportWizard({ accountId, account }: Props) {
{/* Step 1: Upload */}
{step === 'upload' && (
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Upload className="h-4 w-4" />Datei hochladen</CardTitle></CardHeader>
<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" />
<Upload className="text-muted-foreground mb-4 h-10 w-10" />
<p className="text-lg font-semibold">CSV-Datei auswählen</p>
<p className="mt-1 text-sm text-muted-foreground">Semikolon-getrennt (;), UTF-8</p>
<input type="file" accept=".csv" onChange={handleFileUpload}
className="mt-4 block w-full max-w-xs text-sm 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" />
<p className="text-muted-foreground mt-1 text-sm">
Semikolon-getrennt (;), UTF-8
</p>
<input
type="file"
accept=".csv"
onChange={handleFileUpload}
className="file:bg-primary file:text-primary-foreground mt-4 block w-full max-w-xs text-sm file:mr-4 file:rounded-md file:border-0 file:px-4 file:py-2 file:text-sm file:font-semibold"
/>
</div>
</CardContent>
</Card>
@@ -164,33 +216,54 @@ export function MemberImportWizard({ accountId, account }: Props) {
{/* Step 2: Column mapping */}
{step === 'mapping' && (
<Card>
<CardHeader><CardTitle>Spalten zuordnen</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Spalten zuordnen</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4 text-sm text-muted-foreground">{rawData.length} Zeilen erkannt. Ordnen Sie die CSV-Spalten den Mitgliedsfeldern zu.</p>
<p className="text-muted-foreground mb-4 text-sm">
{rawData.length} Zeilen erkannt. Ordnen Sie die CSV-Spalten den
Mitgliedsfeldern zu.
</p>
<div className="space-y-2">
{MEMBER_FIELDS.map(field => (
{MEMBER_FIELDS.map((field) => (
<div key={field.key} className="flex items-center gap-4">
<span className="w-40 text-sm font-medium">{field.label}</span>
<span className="w-40 text-sm font-medium">
{field.label}
</span>
<span className="text-muted-foreground"></span>
<select
value={mapping[field.key] ?? ''}
onChange={(e) => setMapping(prev => ({ ...prev, [field.key]: e.target.value }))}
className="flex h-9 w-64 rounded-md border border-input bg-background px-3 py-1 text-sm"
onChange={(e) =>
setMapping((prev) => ({
...prev,
[field.key]: e.target.value,
}))
}
className="border-input bg-background flex h-9 w-64 rounded-md border px-3 py-1 text-sm"
>
<option value=""> Nicht zuordnen </option>
{headers.map((h, i) => (
<option key={i} value={String(i)}>{h}</option>
<option key={i} value={String(i)}>
{h}
</option>
))}
</select>
{mapping[field.key] !== undefined && rawData[0] && (
<span className="text-xs text-muted-foreground">z.B. &quot;{rawData[0][Number(mapping[field.key])]}&quot;</span>
<span className="text-muted-foreground text-xs">
z.B. &quot;{rawData[0][Number(mapping[field.key])]}&quot;
</span>
)}
</div>
))}
</div>
<div className="mt-6 flex justify-between">
<Button variant="outline" onClick={() => setStep('upload')}><ArrowLeft className="mr-2 h-4 w-4" />Zurück</Button>
<Button onClick={() => setStep('preview')}>Vorschau <ArrowRight className="ml-2 h-4 w-4" /></Button>
<Button variant="outline" onClick={() => setStep('upload')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Zurück
</Button>
<Button onClick={() => setStep('preview')}>
Vorschau <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
@@ -199,26 +272,46 @@ export function MemberImportWizard({ accountId, account }: Props) {
{/* Step 3: Preview + execute */}
{step === 'preview' && (
<Card>
<CardHeader><CardTitle>Vorschau ({rawData.length} Einträge)</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Vorschau ({rawData.length} Einträge)</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-auto rounded-md border max-h-96">
<div className="max-h-96 overflow-auto rounded-md border">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
<th className="p-2 text-left">#</th>
{MEMBER_FIELDS.filter(f => mapping[f.key] !== undefined).map(f => (
<th key={f.key} className="p-2 text-left">{f.label}</th>
{MEMBER_FIELDS.filter(
(f) => mapping[f.key] !== undefined,
).map((f) => (
<th key={f.key} className="p-2 text-left">
{f.label}
</th>
))}
</tr>
</thead>
<tbody>
{rawData.slice(0, 20).map((_, i) => {
const hasName = getMappedValue(i, 'firstName') && getMappedValue(i, 'lastName');
const hasName =
getMappedValue(i, 'firstName') &&
getMappedValue(i, 'lastName');
return (
<tr key={i} className={`border-b ${hasName ? '' : 'bg-red-50 dark:bg-red-950'}`}>
<td className="p-2">{i + 1} {!hasName && <AlertTriangle className="inline h-3 w-3 text-destructive" />}</td>
{MEMBER_FIELDS.filter(f => mapping[f.key] !== undefined).map(f => (
<td key={f.key} className="p-2 max-w-32 truncate">{getMappedValue(i, f.key) || '—'}</td>
<tr
key={i}
className={`border-b ${hasName ? '' : 'bg-red-50 dark:bg-red-950'}`}
>
<td className="p-2">
{i + 1}{' '}
{!hasName && (
<AlertTriangle className="text-destructive inline h-3 w-3" />
)}
</td>
{MEMBER_FIELDS.filter(
(f) => mapping[f.key] !== undefined,
).map((f) => (
<td key={f.key} className="max-w-32 truncate p-2">
{getMappedValue(i, f.key) || '—'}
</td>
))}
</tr>
);
@@ -226,9 +319,16 @@ export function MemberImportWizard({ accountId, account }: Props) {
</tbody>
</table>
</div>
{rawData.length > 20 && <p className="mt-2 text-xs text-muted-foreground">... und {rawData.length - 20} weitere Einträge</p>}
{rawData.length > 20 && (
<p className="text-muted-foreground mt-2 text-xs">
... und {rawData.length - 20} weitere Einträge
</p>
)}
<div className="mt-6 flex justify-between">
<Button variant="outline" onClick={() => setStep('mapping')}><ArrowLeft className="mr-2 h-4 w-4" />Zurück</Button>
<Button variant="outline" onClick={() => setStep('mapping')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Zurück
</Button>
<Button onClick={executeImport}>
<CheckCircle className="mr-2 h-4 w-4" />
{rawData.length} Mitglieder importieren
@@ -242,9 +342,13 @@ export function MemberImportWizard({ accountId, account }: Props) {
{step === 'importing' && (
<Card>
<CardContent className="flex flex-col items-center justify-center p-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="mt-4 text-lg font-semibold">Importiere Mitglieder...</p>
<p className="text-sm text-muted-foreground">Bitte warten Sie, bis der Import abgeschlossen ist.</p>
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent" />
<p className="mt-4 text-lg font-semibold">
Importiere Mitglieder...
</p>
<p className="text-muted-foreground text-sm">
Bitte warten Sie, bis der Import abgeschlossen ist.
</p>
</CardContent>
</Card>
)}
@@ -256,20 +360,28 @@ export function MemberImportWizard({ accountId, account }: Props) {
<CheckCircle className="mx-auto h-12 w-12 text-green-500" />
<h3 className="mt-4 text-xl font-semibold">Import abgeschlossen</h3>
<div className="mt-4 flex justify-center gap-4">
<Badge variant="default">{importResults.success} erfolgreich</Badge>
<Badge variant="default">
{importResults.success} erfolgreich
</Badge>
{importResults.errors.length > 0 && (
<Badge variant="destructive">{importResults.errors.length} Fehler</Badge>
<Badge variant="destructive">
{importResults.errors.length} Fehler
</Badge>
)}
</div>
{importResults.errors.length > 0 && (
<div className="mt-4 max-h-40 overflow-auto rounded-md border p-3 text-left text-xs">
{importResults.errors.map((err, i) => (
<p key={i} className="text-destructive">{err}</p>
<p key={i} className="text-destructive">
{err}
</p>
))}
</div>
)}
<div className="mt-6">
<Button onClick={() => router.push(`/home/${account}/members-cms`)}>
<Button
onClick={() => router.push(`/home/${account}/members-cms`)}
>
Zur Mitgliederliste
</Button>
</div>

View File

@@ -1,13 +1,17 @@
'use client';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter, useSearchParams } from 'next/navigation';
import { toast } from '@kit/ui/sonner';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { formatDate } from '@kit/shared/dates';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { STATUS_LABELS, getMemberStatusColor } from '../lib/member-utils';
@@ -117,7 +121,7 @@ export function MembersDataTable({
<select
value={currentStatus}
onChange={handleStatusChange}
className="flex h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
className="border-input bg-background flex h-9 rounded-md border px-3 py-1 text-sm shadow-sm"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
@@ -128,9 +132,7 @@ export function MembersDataTable({
<Button
size="sm"
onClick={() =>
router.push(`/home/${account}/members-cms/new`)
}
onClick={() => router.push(`/home/${account}/members-cms/new`)}
>
Neues Mitglied
</Button>
@@ -141,7 +143,7 @@ export function MembersDataTable({
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
<th className="px-4 py-3 text-left font-medium">Nr</th>
<th className="px-4 py-3 text-left font-medium">Name</th>
<th className="px-4 py-3 text-left font-medium">E-Mail</th>
@@ -153,7 +155,10 @@ export function MembersDataTable({
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
<td
colSpan={6}
className="text-muted-foreground px-4 py-8 text-center"
>
Keine Mitglieder gefunden.
</td>
</tr>
@@ -165,7 +170,7 @@ export function MembersDataTable({
<tr
key={memberId}
onClick={() => handleRowClick(memberId)}
className="cursor-pointer border-b transition-colors hover:bg-muted/50"
className="hover:bg-muted/50 cursor-pointer border-b transition-colors"
>
<td className="px-4 py-3 font-mono text-xs">
{String(member.member_number ?? '—')}
@@ -174,21 +179,17 @@ export function MembersDataTable({
{String(member.last_name ?? '')},{' '}
{String(member.first_name ?? '')}
</td>
<td className="px-4 py-3 text-muted-foreground">
<td className="text-muted-foreground px-4 py-3">
{String(member.email ?? '—')}
</td>
<td className="px-4 py-3">
{String(member.city ?? '—')}
</td>
<td className="px-4 py-3">{String(member.city ?? '—')}</td>
<td className="px-4 py-3">
<Badge variant={getMemberStatusColor(status)}>
{STATUS_LABELS[status] ?? status}
</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">
{member.entry_date
? new Date(String(member.entry_date)).toLocaleDateString('de-DE')
: '—'}
<td className="text-muted-foreground px-4 py-3">
{formatDate(member.entry_date as string)}
</td>
</tr>
);
@@ -200,7 +201,7 @@ export function MembersDataTable({
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
{total} Mitglied{total !== 1 ? 'er' : ''} insgesamt
</p>
<div className="flex items-center gap-2">

View File

@@ -2,7 +2,9 @@
* Client-side utility functions for member display.
*/
export function computeAge(dateOfBirth: string | null | undefined): number | null {
export function computeAge(
dateOfBirth: string | null | undefined,
): number | null {
if (!dateOfBirth) return null;
const birth = new Date(dateOfBirth);
const today = new Date();
@@ -12,7 +14,9 @@ export function computeAge(dateOfBirth: string | null | undefined): number | nul
return age;
}
export function computeMembershipYears(entryDate: string | null | undefined): number {
export function computeMembershipYears(
entryDate: string | null | undefined,
): number {
if (!entryDate) return 0;
const entry = new Date(entryDate);
const today = new Date();
@@ -22,7 +26,11 @@ export function computeMembershipYears(entryDate: string | null | undefined): nu
return Math.max(0, years);
}
export function formatSalutation(salutation: string | null | undefined, firstName: string, lastName: string): string {
export function formatSalutation(
salutation: string | null | undefined,
firstName: string,
lastName: string,
): string {
if (salutation) return `${salutation} ${firstName} ${lastName}`;
return `${firstName} ${lastName}`;
}
@@ -47,15 +55,22 @@ export function formatIban(iban: string | null | undefined): string {
return cleaned.replace(/(.{4})/g, '$1 ').trim();
}
export function getMemberStatusColor(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
export function getMemberStatusColor(
status: string,
): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case 'active': return 'default';
case 'inactive': return 'secondary';
case 'pending': return 'outline';
case 'active':
return 'default';
case 'inactive':
return 'secondary';
case 'pending':
return 'outline';
case 'resigned':
case 'excluded':
case 'deceased': return 'destructive';
default: return 'secondary';
case 'deceased':
return 'destructive';
default:
return 'secondary';
}
}
@@ -68,7 +83,10 @@ export const STATUS_LABELS: Record<string, string> = {
deceased: 'Verstorben',
};
export const APPLICATION_STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
export const APPLICATION_STATUS_VARIANT: Record<
string,
'default' | 'secondary' | 'destructive' | 'outline'
> = {
submitted: 'outline',
review: 'secondary',
approved: 'default',

View File

@@ -1,12 +1,20 @@
import { z } from 'zod';
export const MembershipStatusEnum = z.enum([
'active', 'inactive', 'pending', 'resigned', 'excluded', 'deceased',
'active',
'inactive',
'pending',
'resigned',
'excluded',
'deceased',
]);
export type MembershipStatus = z.infer<typeof MembershipStatusEnum>;
export const SepaMandateStatusEnum = z.enum([
'active', 'pending', 'revoked', 'expired',
'active',
'pending',
'revoked',
'expired',
]);
export const CreateMemberSchema = z.object({
@@ -78,7 +86,9 @@ export const CreateDuesCategorySchema = z.object({
name: z.string().min(1).max(128),
description: z.string().optional(),
amount: z.number().min(0),
interval: z.enum(['monthly', 'quarterly', 'half_yearly', 'yearly']).default('yearly'),
interval: z
.enum(['monthly', 'quarterly', 'half_yearly', 'yearly'])
.default('yearly'),
isDefault: z.boolean().default(false),
isYouth: z.boolean().default(false),
isExit: z.boolean().default(false),
@@ -130,7 +140,9 @@ export const UpdateDuesCategorySchema = z.object({
name: z.string().min(1).optional(),
description: z.string().optional(),
amount: z.number().min(0).optional(),
interval: z.enum(['monthly', 'quarterly', 'half_yearly', 'yearly']).optional(),
interval: z
.enum(['monthly', 'quarterly', 'half_yearly', 'yearly'])
.optional(),
isDefault: z.boolean().optional(),
});
export type UpdateDuesCategoryInput = z.infer<typeof UpdateDuesCategorySchema>;

View File

@@ -1,9 +1,11 @@
'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
CreateMemberSchema,
UpdateMemberSchema,
@@ -79,7 +81,10 @@ export const approveApplication = authActionClient
const api = createMemberManagementApi(client);
const userId = ctx.user.id;
logger.info({ name: 'member.approveApplication' }, 'Approving application...');
logger.info(
{ name: 'member.approveApplication' },
'Approving application...',
);
const result = await api.approveApplication(input.applicationId, userId);
logger.info({ name: 'member.approveApplication' }, 'Application approved');
return { success: true, data: result };
@@ -91,8 +96,15 @@ export const rejectApplication = authActionClient
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
logger.info({ name: 'members.reject-application' }, 'Rejecting application...');
await api.rejectApplication(input.applicationId, ctx.user.id, input.reviewNotes);
logger.info(
{ name: 'members.reject-application' },
'Rejecting application...',
);
await api.rejectApplication(
input.applicationId,
ctx.user.id,
input.reviewNotes,
);
return { success: true };
});
@@ -202,7 +214,9 @@ export const exportMembers = authActionClient
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const csv = await api.exportMembersCsv(input.accountId, { status: input.status });
const csv = await api.exportMembersCsv(input.accountId, {
status: input.status,
});
return { success: true, csv };
});
@@ -231,63 +245,89 @@ export const exportMembersExcel = authActionClient
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const buffer = await api.exportMembersExcel(input.accountId, { status: input.status });
const buffer = await api.exportMembersExcel(input.accountId, {
status: input.status,
});
// Return base64 for client-side download
return { success: true, base64: buffer.toString('base64'), filename: `mitglieder_${new Date().toISOString().split('T')[0]}.xlsx` };
return {
success: true,
base64: buffer.toString('base64'),
filename: `mitglieder_${new Date().toISOString().split('T')[0]}.xlsx`,
};
});
// Gap 6: Member card PDF generation
export const generateMemberCards = authActionClient
.inputSchema(z.object({
accountId: z.string().uuid(),
memberIds: z.array(z.string().uuid()).optional(),
orgName: z.string().default('Verein'),
}))
.inputSchema(
z.object({
accountId: z.string().uuid(),
memberIds: z.array(z.string().uuid()).optional(),
orgName: z.string().default('Verein'),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
let query = client.from('members').select('id, first_name, last_name, member_number, entry_date, status')
.eq('account_id', input.accountId).eq('status', 'active');
let query = client
.from('members')
.select('id, first_name, last_name, member_number, entry_date, status')
.eq('account_id', input.accountId)
.eq('status', 'active');
if (input.memberIds && input.memberIds.length > 0) {
query = query.in('id', input.memberIds);
}
const { data: members, error } = await query;
if (error) throw error;
const { generateMemberCardsPdf } = await import('../services/member-card-generator');
const { generateMemberCardsPdf } =
await import('../services/member-card-generator');
const buffer = await generateMemberCardsPdf(
input.orgName,
(members ?? []).map((m: any) => ({
firstName: m.first_name, lastName: m.last_name,
memberNumber: m.member_number ?? '', entryDate: m.entry_date ?? '',
firstName: m.first_name,
lastName: m.last_name,
memberNumber: m.member_number ?? '',
entryDate: m.entry_date ?? '',
status: m.status,
})),
);
return { success: true, base64: buffer.toString('base64'), filename: `mitgliedsausweise_${new Date().toISOString().split('T')[0]}.pdf` };
return {
success: true,
base64: buffer.toString('base64'),
filename: `mitgliedsausweise_${new Date().toISOString().split('T')[0]}.pdf`,
};
});
// Portal Invitations
export const inviteMemberToPortal = authActionClient
.inputSchema(z.object({
memberId: z.string().uuid(),
accountId: z.string().uuid(),
email: z.string().email(),
}))
.inputSchema(
z.object({
memberId: z.string().uuid(),
accountId: z.string().uuid(),
email: z.string().email(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
logger.info({ name: 'portal.invite', memberId: input.memberId }, 'Sending portal invitation...');
logger.info(
{ name: 'portal.invite', memberId: input.memberId },
'Sending portal invitation...',
);
const invitation = await api.inviteMemberToPortal(input, ctx.user.id);
// Create auth user for the member if not exists
// In production: send invitation email with the token link
// For now: create the user directly via admin API
logger.info({ name: 'portal.invite', token: invitation.invite_token }, 'Invitation created');
logger.info(
{ name: 'portal.invite', token: invitation.invite_token },
'Invitation created',
);
return { success: true, data: invitation };
});

View File

@@ -1,23 +1,43 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { CreateMemberInput, UpdateMemberInput } from '../schema/member.schema';
import { todayISO } from '@kit/shared/dates';
import type { Database } from '@kit/supabase/database';
import type {
CreateMemberInput,
UpdateMemberInput,
} from '../schema/member.schema';
/**
* Factory for the Member Management API.
*/
export function createMemberManagementApi(client: SupabaseClient<Database>) {
return {
async listMembers(accountId: string, opts?: { status?: string; search?: string; page?: number; pageSize?: number }) {
let query = (client).from('members')
async listMembers(
accountId: string,
opts?: {
status?: string;
search?: string;
page?: number;
pageSize?: number;
},
) {
let query = client
.from('members')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('last_name')
.order('first_name');
if (opts?.status) query = query.eq('status', opts.status as Database['public']['Enums']['membership_status']);
if (opts?.status)
query = query.eq(
'status',
opts.status as Database['public']['Enums']['membership_status'],
);
if (opts?.search) {
query = query.or(`last_name.ilike.%${opts.search}%,first_name.ilike.%${opts.search}%,email.ilike.%${opts.search}%,member_number.ilike.%${opts.search}%`);
query = query.or(
`last_name.ilike.%${opts.search}%,first_name.ilike.%${opts.search}%,email.ilike.%${opts.search}%,member_number.ilike.%${opts.search}%`,
);
}
const page = opts?.page ?? 1;
@@ -30,7 +50,8 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
},
async getMember(memberId: string) {
const { data, error } = await (client).from('members')
const { data, error } = await client
.from('members')
.select('*')
.eq('id', memberId)
.single();
@@ -39,7 +60,8 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
},
async createMember(input: CreateMemberInput, userId: string) {
const { data, error } = await (client).from('members')
const { data, error } = await client
.from('members')
.insert({
account_id: input.accountId,
member_number: input.memberNumber,
@@ -63,7 +85,9 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
bic: input.bic,
account_holder: input.accountHolder,
gdpr_consent: input.gdprConsent,
gdpr_consent_date: input.gdprConsent ? new Date().toISOString() : null,
gdpr_consent_date: input.gdprConsent
? new Date().toISOString()
: null,
notes: input.notes,
// New parity fields
salutation: input.salutation,
@@ -104,59 +128,90 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
async updateMember(input: UpdateMemberInput, userId: string) {
const updateData: Record<string, unknown> = { updated_by: userId };
if (input.firstName !== undefined) updateData.first_name = input.firstName;
if (input.firstName !== undefined)
updateData.first_name = input.firstName;
if (input.lastName !== undefined) updateData.last_name = input.lastName;
if (input.email !== undefined) updateData.email = input.email;
if (input.phone !== undefined) updateData.phone = input.phone;
if (input.mobile !== undefined) updateData.mobile = input.mobile;
if (input.street !== undefined) updateData.street = input.street;
if (input.houseNumber !== undefined) updateData.house_number = input.houseNumber;
if (input.postalCode !== undefined) updateData.postal_code = input.postalCode;
if (input.houseNumber !== undefined)
updateData.house_number = input.houseNumber;
if (input.postalCode !== undefined)
updateData.postal_code = input.postalCode;
if (input.city !== undefined) updateData.city = input.city;
if (input.status !== undefined) updateData.status = input.status;
if (input.duesCategoryId !== undefined) updateData.dues_category_id = input.duesCategoryId;
if (input.duesCategoryId !== undefined)
updateData.dues_category_id = input.duesCategoryId;
if (input.iban !== undefined) updateData.iban = input.iban;
if (input.bic !== undefined) updateData.bic = input.bic;
if (input.accountHolder !== undefined) updateData.account_holder = input.accountHolder;
if (input.accountHolder !== undefined)
updateData.account_holder = input.accountHolder;
if (input.notes !== undefined) updateData.notes = input.notes;
if (input.isArchived !== undefined) updateData.is_archived = input.isArchived;
if (input.isArchived !== undefined)
updateData.is_archived = input.isArchived;
// New parity fields
if (input.salutation !== undefined) updateData.salutation = input.salutation;
if (input.salutation !== undefined)
updateData.salutation = input.salutation;
if (input.street2 !== undefined) updateData.street2 = input.street2;
if (input.phone2 !== undefined) updateData.phone2 = input.phone2;
if (input.fax !== undefined) updateData.fax = input.fax;
if (input.birthplace !== undefined) updateData.birthplace = input.birthplace;
if (input.birthCountry !== undefined) updateData.birth_country = input.birthCountry;
if (input.birthplace !== undefined)
updateData.birthplace = input.birthplace;
if (input.birthCountry !== undefined)
updateData.birth_country = input.birthCountry;
if (input.title !== undefined) updateData.title = input.title;
if (input.dateOfBirth !== undefined) updateData.date_of_birth = input.dateOfBirth;
if (input.dateOfBirth !== undefined)
updateData.date_of_birth = input.dateOfBirth;
if (input.gender !== undefined) updateData.gender = input.gender;
if (input.country !== undefined) updateData.country = input.country;
if (input.entryDate !== undefined) updateData.entry_date = input.entryDate;
if (input.entryDate !== undefined)
updateData.entry_date = input.entryDate;
if (input.exitDate !== undefined) updateData.exit_date = input.exitDate;
if (input.exitReason !== undefined) updateData.exit_reason = input.exitReason;
if (input.isHonorary !== undefined) updateData.is_honorary = input.isHonorary;
if (input.isFoundingMember !== undefined) updateData.is_founding_member = input.isFoundingMember;
if (input.exitReason !== undefined)
updateData.exit_reason = input.exitReason;
if (input.isHonorary !== undefined)
updateData.is_honorary = input.isHonorary;
if (input.isFoundingMember !== undefined)
updateData.is_founding_member = input.isFoundingMember;
if (input.isYouth !== undefined) updateData.is_youth = input.isYouth;
if (input.isRetiree !== undefined) updateData.is_retiree = input.isRetiree;
if (input.isProbationary !== undefined) updateData.is_probationary = input.isProbationary;
if (input.isTransferred !== undefined) updateData.is_transferred = input.isTransferred;
if (input.guardianName !== undefined) updateData.guardian_name = input.guardianName;
if (input.guardianPhone !== undefined) updateData.guardian_phone = input.guardianPhone;
if (input.guardianEmail !== undefined) updateData.guardian_email = input.guardianEmail;
if (input.isRetiree !== undefined)
updateData.is_retiree = input.isRetiree;
if (input.isProbationary !== undefined)
updateData.is_probationary = input.isProbationary;
if (input.isTransferred !== undefined)
updateData.is_transferred = input.isTransferred;
if (input.guardianName !== undefined)
updateData.guardian_name = input.guardianName;
if (input.guardianPhone !== undefined)
updateData.guardian_phone = input.guardianPhone;
if (input.guardianEmail !== undefined)
updateData.guardian_email = input.guardianEmail;
if (input.duesYear !== undefined) updateData.dues_year = input.duesYear;
if (input.duesPaid !== undefined) updateData.dues_paid = input.duesPaid;
if (input.additionalFees !== undefined) updateData.additional_fees = input.additionalFees;
if (input.exemptionType !== undefined) updateData.exemption_type = input.exemptionType;
if (input.exemptionReason !== undefined) updateData.exemption_reason = input.exemptionReason;
if (input.exemptionAmount !== undefined) updateData.exemption_amount = input.exemptionAmount;
if (input.gdprConsent !== undefined) updateData.gdpr_consent = input.gdprConsent;
if (input.gdprNewsletter !== undefined) updateData.gdpr_newsletter = input.gdprNewsletter;
if (input.gdprInternet !== undefined) updateData.gdpr_internet = input.gdprInternet;
if (input.gdprPrint !== undefined) updateData.gdpr_print = input.gdprPrint;
if (input.gdprBirthdayInfo !== undefined) updateData.gdpr_birthday_info = input.gdprBirthdayInfo;
if (input.sepaMandateReference !== undefined) updateData.sepa_mandate_reference = input.sepaMandateReference;
if (input.additionalFees !== undefined)
updateData.additional_fees = input.additionalFees;
if (input.exemptionType !== undefined)
updateData.exemption_type = input.exemptionType;
if (input.exemptionReason !== undefined)
updateData.exemption_reason = input.exemptionReason;
if (input.exemptionAmount !== undefined)
updateData.exemption_amount = input.exemptionAmount;
if (input.gdprConsent !== undefined)
updateData.gdpr_consent = input.gdprConsent;
if (input.gdprNewsletter !== undefined)
updateData.gdpr_newsletter = input.gdprNewsletter;
if (input.gdprInternet !== undefined)
updateData.gdpr_internet = input.gdprInternet;
if (input.gdprPrint !== undefined)
updateData.gdpr_print = input.gdprPrint;
if (input.gdprBirthdayInfo !== undefined)
updateData.gdpr_birthday_info = input.gdprBirthdayInfo;
if (input.sepaMandateReference !== undefined)
updateData.sepa_mandate_reference = input.sepaMandateReference;
const { data, error } = await (client).from('members')
const { data, error } = await client
.from('members')
.update(updateData)
.eq('id', input.memberId)
.select()
@@ -166,20 +221,31 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
},
async deleteMember(memberId: string) {
const { error } = await (client).from('members')
.update({ status: 'resigned', exit_date: new Date().toISOString().split('T')[0] })
const { error } = await client
.from('members')
.update({
status: 'resigned',
exit_date: todayISO(),
})
.eq('id', memberId);
if (error) throw error;
},
async getMemberStatistics(accountId: string) {
const { data, error } = await (client).from('members')
const { data, error } = await client
.from('members')
.select('status')
.eq('account_id', accountId);
if (error) throw error;
const stats = { total: 0, active: 0, inactive: 0, pending: 0, resigned: 0 };
for (const m of (data ?? [])) {
const stats = {
total: 0,
active: 0,
inactive: 0,
pending: 0,
resigned: 0,
};
for (const m of data ?? []) {
stats.total++;
if (m.status === 'active') stats.active++;
else if (m.status === 'inactive') stats.inactive++;
@@ -191,7 +257,8 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
// Dues categories
async listDuesCategories(accountId: string) {
const { data, error } = await (client).from('dues_categories')
const { data, error } = await client
.from('dues_categories')
.select('*')
.eq('account_id', accountId)
.order('sort_order');
@@ -201,11 +268,16 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
// Applications
async listApplications(accountId: string, status?: string) {
let query = (client).from('membership_applications')
let query = client
.from('membership_applications')
.select('*')
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (status) query = query.eq('status', status as Database['public']['Enums']['application_status']);
if (status)
query = query.eq(
'status',
status as Database['public']['Enums']['application_status'],
);
const { data, error } = await query;
if (error) throw error;
return data ?? [];
@@ -213,14 +285,16 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
async approveApplication(applicationId: string, userId: string) {
// Get application
const { data: app, error: appError } = await (client).from('membership_applications')
const { data: app, error: appError } = await client
.from('membership_applications')
.select('*')
.eq('id', applicationId)
.single();
if (appError) throw appError;
// Create member from application
const { data: member, error: memberError } = await (client).from('members')
const { data: member, error: memberError } = await client
.from('members')
.insert({
account_id: app.account_id,
first_name: app.first_name,
@@ -240,7 +314,8 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
if (memberError) throw memberError;
// Update application
await (client).from('membership_applications')
await client
.from('membership_applications')
.update({
status: 'approved',
reviewed_by: userId,
@@ -252,125 +327,242 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
return member;
},
async rejectApplication(applicationId: string, userId: string, reviewNotes?: string) {
const { error } = await client.from('membership_applications')
.update({ status: 'rejected' as any, reviewed_by: userId, reviewed_at: new Date().toISOString(), review_notes: reviewNotes })
async rejectApplication(
applicationId: string,
userId: string,
reviewNotes?: string,
) {
const { error } = await client
.from('membership_applications')
.update({
status: 'rejected' as any,
reviewed_by: userId,
reviewed_at: new Date().toISOString(),
review_notes: reviewNotes,
})
.eq('id', applicationId);
if (error) throw error;
},
async createDuesCategory(input: { accountId: string; name: string; description?: string; amount: number; interval?: string; isDefault?: boolean; isYouth?: boolean; isExit?: boolean }) {
const { data, error } = await client.from('dues_categories').insert({
account_id: input.accountId, name: input.name, description: input.description,
amount: input.amount, interval: input.interval ?? 'yearly',
is_default: input.isDefault ?? false, is_youth: input.isYouth ?? false, is_exit: input.isExit ?? false,
}).select().single();
async createDuesCategory(input: {
accountId: string;
name: string;
description?: string;
amount: number;
interval?: string;
isDefault?: boolean;
isYouth?: boolean;
isExit?: boolean;
}) {
const { data, error } = await client
.from('dues_categories')
.insert({
account_id: input.accountId,
name: input.name,
description: input.description,
amount: input.amount,
interval: input.interval ?? 'yearly',
is_default: input.isDefault ?? false,
is_youth: input.isYouth ?? false,
is_exit: input.isExit ?? false,
})
.select()
.single();
if (error) throw error;
return data;
},
async deleteDuesCategory(categoryId: string) {
const { error } = await client.from('dues_categories').delete().eq('id', categoryId);
const { error } = await client
.from('dues_categories')
.delete()
.eq('id', categoryId);
if (error) throw error;
},
async listDepartments(accountId: string) {
const { data, error } = await client.from('member_departments').select('*')
.eq('account_id', accountId).order('sort_order');
const { data, error } = await client
.from('member_departments')
.select('*')
.eq('account_id', accountId)
.order('sort_order');
if (error) throw error;
return data ?? [];
},
async createDepartment(input: { accountId: string; name: string; description?: string }) {
const { data, error } = await client.from('member_departments').insert({
account_id: input.accountId, name: input.name, description: input.description,
}).select().single();
async createDepartment(input: {
accountId: string;
name: string;
description?: string;
}) {
const { data, error } = await client
.from('member_departments')
.insert({
account_id: input.accountId,
name: input.name,
description: input.description,
})
.select()
.single();
if (error) throw error;
return data;
},
async assignDepartment(memberId: string, departmentId: string) {
const { error } = await client.from('member_department_assignments').insert({
member_id: memberId, department_id: departmentId,
});
const { error } = await client
.from('member_department_assignments')
.insert({
member_id: memberId,
department_id: departmentId,
});
if (error) throw error;
},
async removeDepartment(memberId: string, departmentId: string) {
const { error } = await client.from('member_department_assignments').delete()
.eq('member_id', memberId).eq('department_id', departmentId);
const { error } = await client
.from('member_department_assignments')
.delete()
.eq('member_id', memberId)
.eq('department_id', departmentId);
if (error) throw error;
},
async listMemberRoles(memberId: string) {
const { data, error } = await client.from('member_roles').select('*')
.eq('member_id', memberId).order('from_date', { ascending: false });
const { data, error } = await client
.from('member_roles')
.select('*')
.eq('member_id', memberId)
.order('from_date', { ascending: false });
if (error) throw error;
return data ?? [];
},
async createMemberRole(input: { memberId: string; accountId: string; roleName: string; fromDate?: string; untilDate?: string }) {
const { data, error } = await client.from('member_roles').insert({
member_id: input.memberId, account_id: input.accountId, role_name: input.roleName,
from_date: input.fromDate, until_date: input.untilDate,
}).select().single();
async createMemberRole(input: {
memberId: string;
accountId: string;
roleName: string;
fromDate?: string;
untilDate?: string;
}) {
const { data, error } = await client
.from('member_roles')
.insert({
member_id: input.memberId,
account_id: input.accountId,
role_name: input.roleName,
from_date: input.fromDate,
until_date: input.untilDate,
})
.select()
.single();
if (error) throw error;
return data;
},
async deleteMemberRole(roleId: string) {
const { error } = await client.from('member_roles').delete().eq('id', roleId);
const { error } = await client
.from('member_roles')
.delete()
.eq('id', roleId);
if (error) throw error;
},
async listMemberHonors(memberId: string) {
const { data, error } = await client.from('member_honors').select('*')
.eq('member_id', memberId).order('honor_date', { ascending: false });
const { data, error } = await client
.from('member_honors')
.select('*')
.eq('member_id', memberId)
.order('honor_date', { ascending: false });
if (error) throw error;
return data ?? [];
},
async createMemberHonor(input: { memberId: string; accountId: string; honorName: string; honorDate?: string; description?: string }) {
const { data, error } = await client.from('member_honors').insert({
member_id: input.memberId, account_id: input.accountId, honor_name: input.honorName,
honor_date: input.honorDate, description: input.description,
}).select().single();
async createMemberHonor(input: {
memberId: string;
accountId: string;
honorName: string;
honorDate?: string;
description?: string;
}) {
const { data, error } = await client
.from('member_honors')
.insert({
member_id: input.memberId,
account_id: input.accountId,
honor_name: input.honorName,
honor_date: input.honorDate,
description: input.description,
})
.select()
.single();
if (error) throw error;
return data;
},
async deleteMemberHonor(honorId: string) {
const { error } = await client.from('member_honors').delete().eq('id', honorId);
const { error } = await client
.from('member_honors')
.delete()
.eq('id', honorId);
if (error) throw error;
},
async listMandates(memberId: string) {
const { data, error } = await client.from('sepa_mandates').select('*')
.eq('member_id', memberId).order('is_primary', { ascending: false });
const { data, error } = await client
.from('sepa_mandates')
.select('*')
.eq('member_id', memberId)
.order('is_primary', { ascending: false });
if (error) throw error;
return data ?? [];
},
async createMandate(input: { memberId: string; accountId: string; mandateReference: string; iban: string; bic?: string; accountHolder: string; mandateDate: string; sequence?: string }) {
const { data, error } = await client.from('sepa_mandates').insert({
member_id: input.memberId, account_id: input.accountId,
mandate_reference: input.mandateReference, iban: input.iban, bic: input.bic,
account_holder: input.accountHolder, mandate_date: input.mandateDate,
sequence: input.sequence ?? 'RCUR',
}).select().single();
async createMandate(input: {
memberId: string;
accountId: string;
mandateReference: string;
iban: string;
bic?: string;
accountHolder: string;
mandateDate: string;
sequence?: string;
}) {
const { data, error } = await client
.from('sepa_mandates')
.insert({
member_id: input.memberId,
account_id: input.accountId,
mandate_reference: input.mandateReference,
iban: input.iban,
bic: input.bic,
account_holder: input.accountHolder,
mandate_date: input.mandateDate,
sequence: input.sequence ?? 'RCUR',
})
.select()
.single();
if (error) throw error;
return data;
},
async revokeMandate(mandateId: string) {
const { error } = await client.from('sepa_mandates')
.update({ status: 'revoked' as any }).eq('id', mandateId);
const { error } = await client
.from('sepa_mandates')
.update({ status: 'revoked' as any })
.eq('id', mandateId);
if (error) throw error;
},
async checkDuplicate(accountId: string, firstName: string, lastName: string, dateOfBirth?: string) {
async checkDuplicate(
accountId: string,
firstName: string,
lastName: string,
dateOfBirth?: string,
) {
const { data, error } = await client.rpc('check_duplicate_member', {
p_account_id: accountId, p_first_name: firstName, p_last_name: lastName,
p_account_id: accountId,
p_first_name: firstName,
p_last_name: lastName,
p_date_of_birth: dateOfBirth ?? undefined,
});
if (error) throw error;
@@ -378,58 +570,133 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
},
// --- Update operations (Gap 1) ---
async updateDuesCategory(input: { categoryId: string; name?: string; description?: string; amount?: number; interval?: string; isDefault?: boolean }) {
async updateDuesCategory(input: {
categoryId: string;
name?: string;
description?: string;
amount?: number;
interval?: string;
isDefault?: boolean;
}) {
const updateData: Record<string, unknown> = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.description !== undefined) updateData.description = input.description;
if (input.description !== undefined)
updateData.description = input.description;
if (input.amount !== undefined) updateData.amount = input.amount;
if (input.interval !== undefined) updateData.interval = input.interval;
if (input.isDefault !== undefined) updateData.is_default = input.isDefault;
const { data, error } = await client.from('dues_categories').update(updateData).eq('id', input.categoryId).select().single();
if (input.isDefault !== undefined)
updateData.is_default = input.isDefault;
const { data, error } = await client
.from('dues_categories')
.update(updateData)
.eq('id', input.categoryId)
.select()
.single();
if (error) throw error;
return data;
},
async updateMandate(input: { mandateId: string; iban?: string; bic?: string; accountHolder?: string; sequence?: string }) {
async updateMandate(input: {
mandateId: string;
iban?: string;
bic?: string;
accountHolder?: string;
sequence?: string;
}) {
const updateData: Record<string, unknown> = {};
if (input.iban !== undefined) updateData.iban = input.iban;
if (input.bic !== undefined) updateData.bic = input.bic;
if (input.accountHolder !== undefined) updateData.account_holder = input.accountHolder;
if (input.accountHolder !== undefined)
updateData.account_holder = input.accountHolder;
if (input.sequence !== undefined) updateData.sequence = input.sequence;
const { data, error } = await client.from('sepa_mandates').update(updateData).eq('id', input.mandateId).select().single();
const { data, error } = await client
.from('sepa_mandates')
.update(updateData)
.eq('id', input.mandateId)
.select()
.single();
if (error) throw error;
return data;
},
// --- Export (Gap 2) ---
async exportMembersCsv(accountId: string, filters?: { status?: string }) {
let query = client.from('members').select('*').eq('account_id', accountId).order('last_name');
let query = client
.from('members')
.select('*')
.eq('account_id', accountId)
.order('last_name');
if (filters?.status) query = query.eq('status', filters.status as any);
const { data, error } = await query;
if (error) throw error;
const members = data ?? [];
if (members.length === 0) return '';
const headers = ['Mitgliedsnr.', 'Anrede', 'Vorname', 'Nachname', 'Geburtsdatum', 'E-Mail', 'Telefon', 'Mobil', 'Straße', 'Hausnummer', 'PLZ', 'Ort', 'Status', 'Eintrittsdatum', 'IBAN', 'BIC', 'Kontoinhaber'];
const rows = members.map((m) => [
m.member_number ?? '', m.salutation ?? '', m.first_name, m.last_name,
m.date_of_birth ?? '', m.email ?? '', m.phone ?? '', m.mobile ?? '',
m.street ?? '', m.house_number ?? '', m.postal_code ?? '', m.city ?? '',
m.status, m.entry_date ?? '', m.iban ?? '', m.bic ?? '', m.account_holder ?? '',
].map(v => `"${String(v).replace(/"/g, '""')}"`).join(';'));
const headers = [
'Mitgliedsnr.',
'Anrede',
'Vorname',
'Nachname',
'Geburtsdatum',
'E-Mail',
'Telefon',
'Mobil',
'Straße',
'Hausnummer',
'PLZ',
'Ort',
'Status',
'Eintrittsdatum',
'IBAN',
'BIC',
'Kontoinhaber',
];
const rows = members.map((m) =>
[
m.member_number ?? '',
m.salutation ?? '',
m.first_name,
m.last_name,
m.date_of_birth ?? '',
m.email ?? '',
m.phone ?? '',
m.mobile ?? '',
m.street ?? '',
m.house_number ?? '',
m.postal_code ?? '',
m.city ?? '',
m.status,
m.entry_date ?? '',
m.iban ?? '',
m.bic ?? '',
m.account_holder ?? '',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';'),
);
return [headers.join(';'), ...rows].join('\n');
},
// --- Department assign/remove (Gap 5) ---
async getDepartmentAssignments(memberId: string) {
const { data, error } = await client.from('member_department_assignments').select('department_id').eq('member_id', memberId);
const { data, error } = await client
.from('member_department_assignments')
.select('department_id')
.eq('member_id', memberId);
if (error) throw error;
return (data ?? []).map((d) => d.department_id);
},
async exportMembersExcel(accountId: string, filters?: { status?: string }): Promise<Buffer> {
let query = client.from('members').select('*').eq('account_id', accountId).order('last_name');
async exportMembersExcel(
accountId: string,
filters?: { status?: string },
): Promise<Buffer> {
let query = client
.from('members')
.select('*')
.eq('account_id', accountId)
.order('last_name');
if (filters?.status) query = query.eq('status', filters.status as any);
const { data, error } = await query;
if (error) throw error;
@@ -461,7 +728,11 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
// Style header row
sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8F5E9' } };
sheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE8F5E9' },
};
for (const m of members) {
sheet.addRow(m);
@@ -472,30 +743,49 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
},
// --- Portal Invitations ---
async inviteMemberToPortal(input: { memberId: string; accountId: string; email: string }, invitedBy: string) {
const { data, error } = await client.from('member_portal_invitations').insert({
account_id: input.accountId, member_id: input.memberId, email: input.email, invited_by: invitedBy,
}).select().single();
async inviteMemberToPortal(
input: { memberId: string; accountId: string; email: string },
invitedBy: string,
) {
const { data, error } = await client
.from('member_portal_invitations')
.insert({
account_id: input.accountId,
member_id: input.memberId,
email: input.email,
invited_by: invitedBy,
})
.select()
.single();
if (error) throw error;
return data;
},
async listPortalInvitations(accountId: string) {
const { data, error } = await client.from('member_portal_invitations').select('*')
.eq('account_id', accountId).order('created_at', { ascending: false });
const { data, error } = await client
.from('member_portal_invitations')
.select('*')
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (error) throw error;
return data ?? [];
},
async revokePortalInvitation(invitationId: string) {
const { error } = await client.from('member_portal_invitations')
.update({ status: 'revoked' as any }).eq('id', invitationId);
const { error } = await client
.from('member_portal_invitations')
.update({ status: 'revoked' as any })
.eq('id', invitationId);
if (error) throw error;
},
async getMemberByUserId(accountId: string, userId: string) {
const { data, error } = await client.from('members').select('*')
.eq('account_id', accountId).eq('user_id', userId).maybeSingle();
const { data, error } = await client
.from('members')
.select('*')
.eq('account_id', accountId)
.eq('user_id', userId)
.maybeSingle();
if (error) throw error;
return data;
},

View File

@@ -3,24 +3,48 @@
* Generates A4 pages with member ID cards in a grid layout.
*/
import React from 'react';
import { renderToBuffer } from '@react-pdf/renderer';
import { Document, Page, View, Text, StyleSheet } from '@react-pdf/renderer';
import React from 'react';
const styles = StyleSheet.create({
page: { padding: 20, flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
card: {
width: '48%', height: 180, border: '1pt solid #ccc', borderRadius: 8,
padding: 12, justifyContent: 'space-between',
width: '48%',
height: 180,
border: '1pt solid #ccc',
borderRadius: 8,
padding: 12,
justifyContent: 'space-between',
},
orgName: {
fontSize: 10,
fontWeight: 'bold',
color: '#0d9488',
marginBottom: 6,
},
cardTitle: {
fontSize: 7,
color: '#888',
textTransform: 'uppercase' as const,
letterSpacing: 1,
},
orgName: { fontSize: 10, fontWeight: 'bold', color: '#0d9488', marginBottom: 6 },
cardTitle: { fontSize: 7, color: '#888', textTransform: 'uppercase' as const, letterSpacing: 1 },
memberName: { fontSize: 14, fontWeight: 'bold', marginTop: 4 },
memberNumber: { fontSize: 9, color: '#666', marginTop: 2 },
fieldRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 },
fieldRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 4,
},
fieldLabel: { fontSize: 7, color: '#888' },
fieldValue: { fontSize: 8 },
footer: { fontSize: 6, color: '#aaa', textAlign: 'center' as const, marginTop: 8 },
footer: {
fontSize: 6,
color: '#aaa',
textAlign: 'center' as const,
marginTop: 8,
},
});
interface MemberCardData {
@@ -38,32 +62,80 @@ interface CardPdfProps {
}
function MemberCardDocument({ orgName, members, validYear }: CardPdfProps) {
return React.createElement(Document, {},
React.createElement(Page, { size: 'A4', style: styles.page },
return React.createElement(
Document,
{},
React.createElement(
Page,
{ size: 'A4', style: styles.page },
...members.map((m, i) =>
React.createElement(View, { key: i, style: styles.card },
React.createElement(View, {},
React.createElement(
View,
{ key: i, style: styles.card },
React.createElement(
View,
{},
React.createElement(Text, { style: styles.orgName }, orgName),
React.createElement(Text, { style: styles.cardTitle }, 'MITGLIEDSAUSWEIS'),
React.createElement(Text, { style: styles.memberName }, `${m.firstName} ${m.lastName}`),
React.createElement(Text, { style: styles.memberNumber }, `Nr. ${m.memberNumber || '—'}`),
React.createElement(
Text,
{ style: styles.cardTitle },
'MITGLIEDSAUSWEIS',
),
React.createElement(
Text,
{ style: styles.memberName },
`${m.firstName} ${m.lastName}`,
),
React.createElement(
Text,
{ style: styles.memberNumber },
`Nr. ${m.memberNumber || '—'}`,
),
),
React.createElement(View, {},
React.createElement(View, { style: styles.fieldRow },
React.createElement(View, {},
React.createElement(Text, { style: styles.fieldLabel }, 'Mitglied seit'),
React.createElement(Text, { style: styles.fieldValue }, m.entryDate || '—'),
React.createElement(
View,
{},
React.createElement(
View,
{ style: styles.fieldRow },
React.createElement(
View,
{},
React.createElement(
Text,
{ style: styles.fieldLabel },
'Mitglied seit',
),
React.createElement(
Text,
{ style: styles.fieldValue },
m.entryDate || '—',
),
),
React.createElement(View, {},
React.createElement(Text, { style: styles.fieldLabel }, 'Gültig'),
React.createElement(Text, { style: styles.fieldValue }, String(validYear)),
React.createElement(
View,
{},
React.createElement(
Text,
{ style: styles.fieldLabel },
'Gültig',
),
React.createElement(
Text,
{ style: styles.fieldValue },
String(validYear),
),
),
),
),
React.createElement(Text, { style: styles.footer }, `${orgName} — Mitgliedsausweis ${validYear}`),
)
)
)
React.createElement(
Text,
{ style: styles.footer },
`${orgName} — Mitgliedsausweis ${validYear}`,
),
),
),
),
);
}
@@ -73,7 +145,11 @@ export async function generateMemberCardsPdf(
validYear?: number,
): Promise<Buffer> {
const year = validYear ?? new Date().getFullYear();
const doc = React.createElement(MemberCardDocument, { orgName, members, validYear: year });
const doc = React.createElement(MemberCardDocument, {
orgName,
members,
validYear: year,
});
const buffer = await renderToBuffer(doc as any);
return Buffer.from(buffer);
}

View File

@@ -1,6 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "*.tsx", "src"],
"exclude": ["node_modules"]
}

View File

@@ -1,11 +1,11 @@
'use client';
import type { CmsFieldType } from '../schema/module.schema';
import { Input } from '@kit/ui/input';
import { Textarea } from '@kit/ui/textarea';
import { Checkbox } from '@kit/ui/checkbox';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Textarea } from '@kit/ui/textarea';
import type { CmsFieldType } from '../schema/module.schema';
interface FieldRendererProps {
name: string;
@@ -49,7 +49,15 @@ export function FieldRenderer({
return (
<Input
name={name}
type={fieldType === 'color' ? 'color' : fieldType === 'url' ? 'url' : fieldType === 'phone' ? 'tel' : 'text'}
type={
fieldType === 'color'
? 'color'
: fieldType === 'url'
? 'url'
: fieldType === 'phone'
? 'tel'
: 'text'
}
value={fieldValue}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
@@ -133,7 +141,9 @@ export function FieldRenderer({
step="0.01"
value={fieldValue}
onChange={(e) => onChange(parseFloat(e.target.value) || '')}
placeholder={placeholder ?? (fieldType === 'currency' ? '0,00 €' : undefined)}
placeholder={
placeholder ?? (fieldType === 'currency' ? '0,00 €' : undefined)
}
readOnly={readonly}
required={required}
/>
@@ -185,7 +195,7 @@ export function FieldRenderer({
onChange={(e) => onChange(e.target.value)}
disabled={readonly}
required={required}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background"
className="border-input bg-background ring-offset-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value="">{placeholder ?? 'Bitte wählen...'}</option>
{selectOptions?.map((opt) => (
@@ -202,7 +212,9 @@ export function FieldRenderer({
name={name}
type="text"
value={fieldValue}
onChange={(e) => onChange(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))}
onChange={(e) =>
onChange(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))
}
placeholder={placeholder ?? 'DE89 3704 0044 0532 0130 00'}
readOnly={readonly}
required={required}
@@ -229,7 +241,7 @@ export function FieldRenderer({
case 'computed':
return (
<div className="rounded-md border bg-muted px-3 py-2 text-sm">
<div className="bg-muted rounded-md border px-3 py-2 text-sm">
{fieldValue || '—'}
</div>
);
@@ -253,8 +265,10 @@ export function FieldRenderer({
return (
<div className="space-y-1">
{renderField()}
{helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
{error && <p className="text-xs text-destructive">{error}</p>}
{helpText && (
<p className="text-muted-foreground text-xs">{helpText}</p>
)}
{error && <p className="text-destructive text-xs">{error}</p>}
</div>
);
}
@@ -266,8 +280,8 @@ export function FieldRenderer({
{required && <span className="text-destructive ml-1">*</span>}
</Label>
{renderField()}
{helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
{error && <p className="text-xs text-destructive">{error}</p>}
{helpText && <p className="text-muted-foreground text-xs">{helpText}</p>}
{error && <p className="text-destructive text-xs">{error}</p>}
</div>
);
}

View File

@@ -2,9 +2,8 @@
import { useState } from 'react';
import { FieldRenderer } from './field-renderer';
import type { CmsFieldType } from '../schema/module.schema';
import { FieldRenderer } from './field-renderer';
interface FieldDefinition {
name: string;
@@ -40,7 +39,8 @@ export function ModuleForm({
isLoading = false,
errors = [],
}: ModuleFormProps) {
const [formData, setFormData] = useState<Record<string, unknown>>(initialData);
const [formData, setFormData] =
useState<Record<string, unknown>>(initialData);
const visibleFields = fields
.filter((f) => f.show_in_form)
@@ -71,10 +71,14 @@ export function ModuleForm({
const getWidthClass = (width: string) => {
switch (width) {
case 'half': return 'col-span-1';
case 'third': return 'col-span-1';
case 'quarter': return 'col-span-1';
default: return 'col-span-full';
case 'half':
return 'col-span-1';
case 'third':
return 'col-span-1';
case 'quarter':
return 'col-span-1';
default:
return 'col-span-full';
}
};
@@ -83,11 +87,11 @@ export function ModuleForm({
{Array.from(sections.entries()).map(([sectionName, sectionFields]) => (
<div key={sectionName} className="space-y-4">
{sectionName !== 'default' && (
<h3 className="text-lg font-semibold border-b pb-2">
<h3 className="border-b pb-2 text-lg font-semibold">
{sectionName}
</h3>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{sectionFields.map((field) => (
<div key={field.name} className={getWidthClass(field.width)}>
<FieldRenderer
@@ -109,11 +113,11 @@ export function ModuleForm({
</div>
))}
<div className="flex justify-end gap-2 pt-4 border-t">
<div className="flex justify-end gap-2 border-t pt-4">
<button
type="submit"
disabled={isLoading}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{isLoading ? 'Wird gespeichert...' : 'Speichern'}
</button>

View File

@@ -57,7 +57,10 @@ export function ModuleSearch({
const addFilter = () => {
if (filterableFields.length === 0) return;
setFilters([...filters, { field: filterableFields[0]!.name, operator: 'eq', value: '' }]);
setFilters([
...filters,
{ field: filterableFields[0]!.name, operator: 'eq', value: '' },
]);
};
const removeFilter = (index: number) => {
@@ -66,14 +69,22 @@ export function ModuleSearch({
onFilter(next);
};
const updateFilter = (index: number, key: keyof FilterValue, value: string) => {
const updateFilter = (
index: number,
key: keyof FilterValue,
value: string,
) => {
const next = [...filters];
next[index] = { ...next[index]!, [key]: value };
setFilters(next);
};
const applyFilters = () => {
onFilter(filters.filter((f) => f.value || f.operator === 'is_null' || f.operator === 'not_null'));
onFilter(
filters.filter(
(f) => f.value || f.operator === 'is_null' || f.operator === 'not_null',
),
);
};
return (
@@ -88,7 +99,7 @@ export function ModuleSearch({
/>
<button
type="submit"
className="rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground hover:bg-primary/90"
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-3 py-2 text-sm"
>
Suchen
</button>
@@ -96,7 +107,7 @@ export function ModuleSearch({
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
className="hover:bg-muted rounded-md border px-3 py-2 text-sm"
>
Filter {showAdvanced ? '▲' : '▼'}
</button>
@@ -104,35 +115,40 @@ export function ModuleSearch({
</form>
{showAdvanced && (
<div className="rounded-md border p-4 space-y-3">
<div className="space-y-3 rounded-md border p-4">
{filters.map((filter, i) => (
<div key={i} className="flex gap-2 items-center">
<div key={i} className="flex items-center gap-2">
<select
value={filter.field}
onChange={(e) => updateFilter(i, 'field', e.target.value)}
className="rounded-md border px-2 py-1.5 text-sm bg-background"
className="bg-background rounded-md border px-2 py-1.5 text-sm"
>
{filterableFields.map((f) => (
<option key={f.name} value={f.name}>{f.display_name}</option>
<option key={f.name} value={f.name}>
{f.display_name}
</option>
))}
</select>
<select
value={filter.operator}
onChange={(e) => updateFilter(i, 'operator', e.target.value)}
className="rounded-md border px-2 py-1.5 text-sm bg-background"
className="bg-background rounded-md border px-2 py-1.5 text-sm"
>
{OPERATORS.map((op) => (
<option key={op.value} value={op.value}>{op.label}</option>
<option key={op.value} value={op.value}>
{op.label}
</option>
))}
</select>
{filter.operator !== 'is_null' && filter.operator !== 'not_null' && (
<Input
value={filter.value}
onChange={(e) => updateFilter(i, 'value', e.target.value)}
placeholder="Wert..."
className="max-w-xs"
/>
)}
{filter.operator !== 'is_null' &&
filter.operator !== 'not_null' && (
<Input
value={filter.value}
onChange={(e) => updateFilter(i, 'value', e.target.value)}
placeholder="Wert..."
className="max-w-xs"
/>
)}
<button
type="button"
onClick={() => removeFilter(i)}
@@ -146,7 +162,7 @@ export function ModuleSearch({
<button
type="button"
onClick={addFilter}
className="rounded-md border px-3 py-1.5 text-sm hover:bg-muted"
className="hover:bg-muted rounded-md border px-3 py-1.5 text-sm"
>
+ Filter hinzufügen
</button>
@@ -154,7 +170,7 @@ export function ModuleSearch({
<button
type="button"
onClick={applyFilters}
className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-3 py-1.5 text-sm"
>
Filter anwenden
</button>

View File

@@ -1,5 +1,7 @@
'use client';
import { formatDate } from '@kit/shared/dates';
import type { CmsFieldType } from '../schema/module.schema';
interface FieldDefinition {
@@ -65,9 +67,12 @@ export function ModuleTable({
case 'checkbox':
return value ? '✓' : '✗';
case 'currency':
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(Number(value));
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(Number(value));
case 'date':
try { return new Date(String(value)).toLocaleDateString('de-DE'); } catch { return String(value); }
return formatDate(value as string | Date | null | undefined);
case 'password':
return '••••••';
default:
@@ -76,22 +81,27 @@ export function ModuleTable({
};
const handleSort = (fieldName: string) => {
const newDirection = currentSort?.field === fieldName && currentSort.direction === 'asc'
? 'desc' : 'asc';
const newDirection =
currentSort?.field === fieldName && currentSort.direction === 'asc'
? 'desc'
: 'asc';
onSort(fieldName, newDirection);
};
return (
<div className="space-y-4">
<div className="rounded-md border overflow-auto">
<div className="overflow-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
{onSelectionChange && (
<th className="p-3 w-10">
<th className="w-10 p-3">
<input
type="checkbox"
checked={records.length > 0 && records.every((r) => selectedIds.has(r.id))}
checked={
records.length > 0 &&
records.every((r) => selectedIds.has(r.id))
}
onChange={(e) => {
if (e.target.checked) {
onSelectionChange(new Set(records.map((r) => r.id)));
@@ -105,13 +115,15 @@ export function ModuleTable({
{visibleFields.map((field) => (
<th
key={field.name}
className="p-3 text-left font-medium cursor-pointer hover:bg-muted/80 select-none"
className="hover:bg-muted/80 cursor-pointer p-3 text-left font-medium select-none"
onClick={() => field.is_sortable && handleSort(field.name)}
>
<span className="flex items-center gap-1">
{field.display_name}
{currentSort?.field === field.name && (
<span className="text-xs">{currentSort.direction === 'asc' ? '↑' : '↓'}</span>
<span className="text-xs">
{currentSort.direction === 'asc' ? '↑' : '↓'}
</span>
)}
</span>
</th>
@@ -123,7 +135,7 @@ export function ModuleTable({
<tr>
<td
colSpan={visibleFields.length + (onSelectionChange ? 1 : 0)}
className="p-8 text-center text-muted-foreground"
className="text-muted-foreground p-8 text-center"
>
Keine Datensätze gefunden
</td>
@@ -132,7 +144,7 @@ export function ModuleTable({
records.map((record) => (
<tr
key={record.id}
className="border-b hover:bg-muted/30 cursor-pointer transition-colors"
className="hover:bg-muted/30 cursor-pointer border-b transition-colors"
onClick={() => onRowClick?.(record.id)}
>
{onSelectionChange && (
@@ -154,7 +166,10 @@ export function ModuleTable({
)}
{visibleFields.map((field) => (
<td key={field.name} className="p-3">
{formatCellValue(record.data[field.name], field.field_type)}
{formatCellValue(
record.data[field.name],
field.field_type,
)}
</td>
))}
</tr>
@@ -167,21 +182,22 @@ export function ModuleTable({
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between px-2">
<span className="text-sm text-muted-foreground">
{pagination.total} Datensätze Seite {pagination.page} von {pagination.totalPages}
<span className="text-muted-foreground text-sm">
{pagination.total} Datensätze Seite {pagination.page} von{' '}
{pagination.totalPages}
</span>
<div className="flex gap-1">
<button
onClick={() => onPageChange(pagination.page - 1)}
disabled={pagination.page <= 1}
className="rounded border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50"
className="hover:bg-muted rounded border px-3 py-1 text-sm disabled:opacity-50"
>
Zurück
</button>
<button
onClick={() => onPageChange(pagination.page + 1)}
disabled={pagination.page >= pagination.totalPages}
className="rounded border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50"
className="hover:bg-muted rounded border px-3 py-1 text-sm disabled:opacity-50"
>
Weiter
</button>

View File

@@ -47,7 +47,7 @@ export function ModuleToolbar({
{permissions.canInsert && (
<button
onClick={onNew}
className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-3 py-2 text-sm font-medium"
>
+ Neu
</button>
@@ -56,7 +56,7 @@ export function ModuleToolbar({
{selectedCount > 0 && permissions.canDelete && (
<button
onClick={onDelete}
className="rounded-md border border-destructive px-3 py-2 text-sm text-destructive hover:bg-destructive/10"
className="border-destructive text-destructive hover:bg-destructive/10 rounded-md border px-3 py-2 text-sm"
>
Löschen ({selectedCount})
</button>
@@ -65,7 +65,7 @@ export function ModuleToolbar({
{selectedCount > 0 && permissions.canLock && features.enableLock && (
<button
onClick={onLock}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
className="hover:bg-muted rounded-md border px-3 py-2 text-sm"
>
Sperren ({selectedCount})
</button>
@@ -76,7 +76,7 @@ export function ModuleToolbar({
{permissions.canImport && features.enableImport && (
<button
onClick={onImport}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
className="hover:bg-muted rounded-md border px-3 py-2 text-sm"
>
Import
</button>
@@ -85,7 +85,7 @@ export function ModuleToolbar({
{permissions.canExport && features.enableExport && (
<button
onClick={onExport}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
className="hover:bg-muted rounded-md border px-3 py-2 text-sm"
>
Export
</button>
@@ -94,7 +94,7 @@ export function ModuleToolbar({
{permissions.canPrint && features.enablePrint && (
<button
onClick={onPrint}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
className="hover:bg-muted rounded-md border px-3 py-2 text-sm"
>
Drucken
</button>

View File

@@ -7,9 +7,14 @@ import { CmsFieldTypeEnum } from './module.schema';
*/
export const CreateFieldSchema = z.object({
moduleId: z.string().uuid(),
name: z.string().min(1).max(64).regex(/^[a-z][a-z0-9_]*$/, {
message: 'Field name must start with a letter and contain only lowercase letters, numbers, and underscores',
}),
name: z
.string()
.min(1)
.max(64)
.regex(/^[a-z][a-z0-9_]*$/, {
message:
'Field name must start with a letter and contain only lowercase letters, numbers, and underscores',
}),
displayName: z.string().min(1).max(128),
fieldType: CmsFieldTypeEnum,
sqlType: z.string().max(64).default('text'),
@@ -53,10 +58,14 @@ export const CreateFieldSchema = z.object({
lookupValueField: z.string().optional(),
// Select options
selectOptions: z.array(z.object({
label: z.string(),
value: z.string(),
})).optional(),
selectOptions: z
.array(
z.object({
label: z.string(),
value: z.string(),
}),
)
.optional(),
// GDPR
isPersonalData: z.boolean().default(false),

View File

@@ -6,7 +6,9 @@ import { z } from 'zod';
export const ColumnMappingSchema = z.object({
sourceColumn: z.string(),
targetField: z.string(),
transform: z.enum(['none', 'trim', 'lowercase', 'uppercase', 'date_dmy', 'date_mdy']).default('none'),
transform: z
.enum(['none', 'trim', 'lowercase', 'uppercase', 'date_dmy', 'date_mdy'])
.default('none'),
});
export type ColumnMapping = z.infer<typeof ColumnMappingSchema>;

View File

@@ -5,7 +5,17 @@ import { z } from 'zod';
*/
export const FilterSchema = z.object({
field: z.string().min(1),
operator: z.enum(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'is_null', 'not_null']),
operator: z.enum([
'eq',
'neq',
'gt',
'gte',
'lt',
'lte',
'like',
'is_null',
'not_null',
]),
value: z.string().optional(),
});

View File

@@ -5,10 +5,26 @@ import { z } from 'zod';
* Maps 1:1 to the cms_field_type Postgres enum.
*/
export const CmsFieldTypeEnum = z.enum([
'text', 'textarea', 'richtext', 'checkbox', 'radio', 'hidden',
'select', 'password', 'file', 'date', 'time', 'decimal',
'integer', 'email', 'phone', 'url', 'currency', 'iban',
'color', 'computed',
'text',
'textarea',
'richtext',
'checkbox',
'radio',
'hidden',
'select',
'password',
'file',
'date',
'time',
'decimal',
'integer',
'email',
'phone',
'url',
'currency',
'iban',
'color',
'computed',
]);
export type CmsFieldType = z.infer<typeof CmsFieldTypeEnum>;
@@ -16,7 +32,12 @@ export type CmsFieldType = z.infer<typeof CmsFieldTypeEnum>;
export const CmsModuleStatusEnum = z.enum(['active', 'inactive', 'archived']);
export type CmsModuleStatus = z.infer<typeof CmsModuleStatusEnum>;
export const CmsRecordStatusEnum = z.enum(['active', 'locked', 'deleted', 'archived']);
export const CmsRecordStatusEnum = z.enum([
'active',
'locked',
'deleted',
'archived',
]);
export type CmsRecordStatus = z.infer<typeof CmsRecordStatusEnum>;
/**
@@ -24,9 +45,14 @@ export type CmsRecordStatus = z.infer<typeof CmsRecordStatusEnum>;
*/
export const CreateModuleSchema = z.object({
accountId: z.string().uuid(),
name: z.string().min(1).max(64).regex(/^[a-z][a-z0-9_]*$/, {
message: 'Module name must start with a letter and contain only lowercase letters, numbers, and underscores',
}),
name: z
.string()
.min(1)
.max(64)
.regex(/^[a-z][a-z0-9_]*$/, {
message:
'Module name must start with a letter and contain only lowercase letters, numbers, and underscores',
}),
displayName: z.string().min(1).max(128),
description: z.string().max(1024).optional(),
icon: z.string().max(64).default('table'),

View File

@@ -4,7 +4,10 @@ import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateModuleSchema, UpdateModuleSchema } from '../../schema/module.schema';
import {
CreateModuleSchema,
UpdateModuleSchema,
} from '../../schema/module.schema';
import { createModuleBuilderApi } from '../api';
export const createModule = authActionClient
@@ -14,11 +17,17 @@ export const createModule = authActionClient
const logger = await getLogger();
const api = createModuleBuilderApi(client);
logger.info({ name: 'modules.create', moduleName: input.name }, 'Creating module...');
logger.info(
{ name: 'modules.create', moduleName: input.name },
'Creating module...',
);
const module = await api.modules.createModule(input);
logger.info({ name: 'modules.create', moduleId: module.id }, 'Module created');
logger.info(
{ name: 'modules.create', moduleId: module.id },
'Module created',
);
return { success: true, module };
});
@@ -30,11 +39,17 @@ export const updateModule = authActionClient
const logger = await getLogger();
const api = createModuleBuilderApi(client);
logger.info({ name: 'modules.update', moduleId: input.moduleId }, 'Updating module...');
logger.info(
{ name: 'modules.update', moduleId: input.moduleId },
'Updating module...',
);
const module = await api.modules.updateModule(input);
logger.info({ name: 'modules.update', moduleId: module.id }, 'Module updated');
logger.info(
{ name: 'modules.update', moduleId: module.id },
'Module updated',
);
return { success: true, module };
});

View File

@@ -6,7 +6,12 @@ import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateRecordSchema, UpdateRecordSchema, DeleteRecordSchema, LockRecordSchema } from '../../schema/module-record.schema';
import {
CreateRecordSchema,
UpdateRecordSchema,
DeleteRecordSchema,
LockRecordSchema,
} from '../../schema/module-record.schema';
import { createModuleBuilderApi } from '../api';
import { validateRecordData } from '../services/record-validation.service';
@@ -19,14 +24,28 @@ export const createRecord = authActionClient
const userId = ctx.user.id;
// Get field definitions for validation
const moduleWithFields = await api.modules.getModuleWithFields(input.moduleId);
const moduleWithFields = await api.modules.getModuleWithFields(
input.moduleId,
);
if (!moduleWithFields) {
throw new Error('Module not found');
}
// Validate data against field definitions
const fields = (moduleWithFields as unknown as { fields: Array<{ name: string; field_type: string; is_required: boolean; min_value?: number | null; max_value?: number | null; max_length?: number | null; regex_pattern?: string | null }> }).fields;
const fields = (
moduleWithFields as unknown as {
fields: Array<{
name: string;
field_type: string;
is_required: boolean;
min_value?: number | null;
max_value?: number | null;
max_length?: number | null;
regex_pattern?: string | null;
}>;
}
).fields;
const validation = validateRecordData(
input.data as Record<string, unknown>,
fields as Parameters<typeof validateRecordData>[1],
@@ -36,7 +55,10 @@ export const createRecord = authActionClient
return { success: false, errors: validation.errors };
}
logger.info({ name: 'records.create', moduleId: input.moduleId }, 'Creating record...');
logger.info(
{ name: 'records.create', moduleId: input.moduleId },
'Creating record...',
);
const record = await api.records.createRecord(input, userId);
@@ -64,7 +86,10 @@ export const updateRecord = authActionClient
// Get existing record for audit
const existing = await api.records.getRecord(input.recordId);
logger.info({ name: 'records.update', recordId: input.recordId }, 'Updating record...');
logger.info(
{ name: 'records.update', recordId: input.recordId },
'Updating record...',
);
const record = await api.records.updateRecord(input, userId);
@@ -93,7 +118,10 @@ export const deleteRecord = authActionClient
// Get existing record for audit
const existing = await api.records.getRecord(input.recordId);
logger.info({ name: 'records.delete', recordId: input.recordId, hard: input.hard }, 'Deleting record...');
logger.info(
{ name: 'records.delete', recordId: input.recordId, hard: input.hard },
'Deleting record...',
);
await api.records.deleteRecord(input);

View File

@@ -2,10 +2,10 @@ import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import { createAuditService } from './services/audit.service';
import { createModuleDefinitionService } from './services/module-definition.service';
import { createModuleQueryService } from './services/module-query.service';
import { createRecordCrudService } from './services/record-crud.service';
import { createAuditService } from './services/audit.service';
/**
* Factory for the Module Builder API.

View File

@@ -1,5 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
type Json = Database['public']['Tables']['audit_log']['Insert']['old_data'];
@@ -26,7 +27,11 @@ export function createAuditService(client: SupabaseClient<Database>) {
});
if (error) {
console.error('[audit] Failed to write audit log:', error.message);
const logger = await getLogger();
logger.error(
{ error, context: 'audit-log' },
'[audit] Failed to write audit log',
);
}
},
};

View File

@@ -1,13 +1,19 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { CreateModuleInput, UpdateModuleInput } from '../../schema/module.schema';
import type { Database } from '@kit/supabase/database';
import type {
CreateModuleInput,
UpdateModuleInput,
} from '../../schema/module.schema';
/**
* Service for managing module definitions (CRUD).
* Note: Uses untyped client for new CMS tables until typegen runs.
*/
export function createModuleDefinitionService(client: SupabaseClient<Database>) {
export function createModuleDefinitionService(
client: SupabaseClient<Database>,
) {
return {
async listModules(accountId: string) {
const { data, error } = await client
@@ -78,24 +84,40 @@ export function createModuleDefinitionService(client: SupabaseClient<Database>)
async updateModule(input: UpdateModuleInput) {
const updateData: Record<string, unknown> = {};
if (input.displayName !== undefined) updateData.display_name = input.displayName;
if (input.description !== undefined) updateData.description = input.description;
if (input.displayName !== undefined)
updateData.display_name = input.displayName;
if (input.description !== undefined)
updateData.description = input.description;
if (input.icon !== undefined) updateData.icon = input.icon;
if (input.status !== undefined) updateData.status = input.status;
if (input.sortOrder !== undefined) updateData.sort_order = input.sortOrder;
if (input.defaultSortField !== undefined) updateData.default_sort_field = input.defaultSortField;
if (input.defaultSortDirection !== undefined) updateData.default_sort_direction = input.defaultSortDirection;
if (input.defaultPageSize !== undefined) updateData.default_page_size = input.defaultPageSize;
if (input.enableSearch !== undefined) updateData.enable_search = input.enableSearch;
if (input.enableFilter !== undefined) updateData.enable_filter = input.enableFilter;
if (input.enableExport !== undefined) updateData.enable_export = input.enableExport;
if (input.enableImport !== undefined) updateData.enable_import = input.enableImport;
if (input.enablePrint !== undefined) updateData.enable_print = input.enablePrint;
if (input.enableCopy !== undefined) updateData.enable_copy = input.enableCopy;
if (input.enableBulkEdit !== undefined) updateData.enable_bulk_edit = input.enableBulkEdit;
if (input.enableHistory !== undefined) updateData.enable_history = input.enableHistory;
if (input.enableSoftDelete !== undefined) updateData.enable_soft_delete = input.enableSoftDelete;
if (input.enableLock !== undefined) updateData.enable_lock = input.enableLock;
if (input.sortOrder !== undefined)
updateData.sort_order = input.sortOrder;
if (input.defaultSortField !== undefined)
updateData.default_sort_field = input.defaultSortField;
if (input.defaultSortDirection !== undefined)
updateData.default_sort_direction = input.defaultSortDirection;
if (input.defaultPageSize !== undefined)
updateData.default_page_size = input.defaultPageSize;
if (input.enableSearch !== undefined)
updateData.enable_search = input.enableSearch;
if (input.enableFilter !== undefined)
updateData.enable_filter = input.enableFilter;
if (input.enableExport !== undefined)
updateData.enable_export = input.enableExport;
if (input.enableImport !== undefined)
updateData.enable_import = input.enableImport;
if (input.enablePrint !== undefined)
updateData.enable_print = input.enablePrint;
if (input.enableCopy !== undefined)
updateData.enable_copy = input.enableCopy;
if (input.enableBulkEdit !== undefined)
updateData.enable_bulk_edit = input.enableBulkEdit;
if (input.enableHistory !== undefined)
updateData.enable_history = input.enableHistory;
if (input.enableSoftDelete !== undefined)
updateData.enable_soft_delete = input.enableSoftDelete;
if (input.enableLock !== undefined)
updateData.enable_lock = input.enableLock;
const { data, error } = await client
.from('modules')

View File

@@ -1,6 +1,7 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type { ModuleQueryInput } from '../../schema/module-query.schema';
/**

View File

@@ -1,7 +1,13 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type { CreateRecordInput, UpdateRecordInput, DeleteRecordInput, LockRecordInput } from '../../schema/module-record.schema';
import type {
CreateRecordInput,
UpdateRecordInput,
DeleteRecordInput,
LockRecordInput,
} from '../../schema/module-record.schema';
type Json = Database['public']['Tables']['module_records']['Insert']['data'];

View File

@@ -88,15 +88,19 @@ export function buildFieldValidator(field: FieldDefinition): z.ZodTypeAny {
case 'integer':
schema = z.coerce.number().int();
if (field.min_value != null) schema = (schema as z.ZodNumber).min(field.min_value);
if (field.max_value != null) schema = (schema as z.ZodNumber).max(field.max_value);
if (field.min_value != null)
schema = (schema as z.ZodNumber).min(field.min_value);
if (field.max_value != null)
schema = (schema as z.ZodNumber).max(field.max_value);
break;
case 'decimal':
case 'currency':
schema = z.coerce.number();
if (field.min_value != null) schema = (schema as z.ZodNumber).min(field.min_value);
if (field.max_value != null) schema = (schema as z.ZodNumber).max(field.max_value);
if (field.min_value != null)
schema = (schema as z.ZodNumber).min(field.min_value);
if (field.max_value != null)
schema = (schema as z.ZodNumber).max(field.max_value);
break;
case 'date':
@@ -140,7 +144,9 @@ export function buildFieldValidator(field: FieldDefinition): z.ZodTypeAny {
/**
* Build a complete Zod object schema from an array of field definitions.
*/
export function buildRecordValidator(fields: FieldDefinition[]): z.ZodObject<Record<string, z.ZodTypeAny>> {
export function buildRecordValidator(
fields: FieldDefinition[],
): z.ZodObject<Record<string, z.ZodTypeAny>> {
const shape: Record<string, z.ZodTypeAny> = {};
for (const field of fields) {

View File

@@ -1,14 +1,24 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { CreateNewsletterSchema } from '../schema/newsletter.schema';
import { createNewsletter } from '../server/actions/newsletter-actions';
@@ -44,55 +54,98 @@ export function CreateNewsletterForm({ accountId, account }: Props) {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="space-y-6"
>
<Card>
<CardHeader><CardTitle>Newsletter-Inhalt</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Newsletter-Inhalt</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField control={form.control} name="subject" render={({ field }) => (
<FormItem><FormLabel>Betreff *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="bodyHtml" render={({ field }) => (
<FormItem><FormLabel>Inhalt (HTML) *</FormLabel><FormControl>
<textarea
{...field}
rows={12}
className="flex min-h-[200px] w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm"
placeholder="<h1>Hallo!</h1><p>Ihr Newsletter-Inhalt...</p>"
/>
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="bodyText" render={({ field }) => (
<FormItem><FormLabel>Nur-Text-Version (optional)</FormLabel><FormControl>
<textarea
{...field}
rows={4}
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="Nur-Text-Fallback für E-Mail-Clients ohne HTML-Unterstützung"
/>
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel>Betreff *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bodyHtml"
render={({ field }) => (
<FormItem>
<FormLabel>Inhalt (HTML) *</FormLabel>
<FormControl>
<textarea
{...field}
rows={12}
className="border-input bg-background flex min-h-[200px] w-full rounded-md border px-3 py-2 font-mono text-sm"
placeholder="<h1>Hallo!</h1><p>Ihr Newsletter-Inhalt...</p>"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bodyText"
render={({ field }) => (
<FormItem>
<FormLabel>Nur-Text-Version (optional)</FormLabel>
<FormControl>
<textarea
{...field}
rows={4}
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
placeholder="Nur-Text-Fallback für E-Mail-Clients ohne HTML-Unterstützung"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Zeitplan</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Zeitplan</CardTitle>
</CardHeader>
<CardContent>
<FormField control={form.control} name="scheduledAt" render={({ field }) => (
<FormItem>
<FormLabel>Geplanter Versand (optional)</FormLabel>
<FormControl><Input type="datetime-local" {...field} /></FormControl>
<p className="text-xs text-muted-foreground">
Leer lassen, um den Newsletter als Entwurf zu speichern.
</p>
<FormMessage />
</FormItem>
)} />
<FormField
control={form.control}
name="scheduledAt"
render={({ field }) => (
<FormItem>
<FormLabel>Geplanter Versand (optional)</FormLabel>
<FormControl>
<Input type="datetime-local" {...field} />
</FormControl>
<p className="text-muted-foreground text-xs">
Leer lassen, um den Newsletter als Entwurf zu speichern.
</p>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Newsletter erstellen'}</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird erstellt...' : 'Newsletter erstellen'}
</Button>
</div>
</form>
</Form>

View File

@@ -1,6 +1,12 @@
import { z } from 'zod';
export const NewsletterStatusEnum = z.enum(['draft', 'scheduled', 'sending', 'sent', 'failed']);
export const NewsletterStatusEnum = z.enum([
'draft',
'scheduled',
'sending',
'sent',
'failed',
]);
export const CreateNewsletterSchema = z.object({
accountId: z.string().uuid(),
@@ -23,10 +29,12 @@ export const CreateTemplateSchema = z.object({
export const SelectRecipientsSchema = z.object({
newsletterId: z.string().uuid(),
memberFilter: z.object({
status: z.array(z.string()).optional(),
duesCategoryId: z.string().uuid().optional(),
hasEmail: z.boolean().default(true),
}).optional(),
memberFilter: z
.object({
status: z.array(z.string()).optional(),
duesCategoryId: z.string().uuid().optional(),
hasEmail: z.boolean().default(true),
})
.optional(),
manualEmails: z.array(z.string().email()).optional(),
});

View File

@@ -1,9 +1,11 @@
'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
CreateNewsletterSchema,
CreateTemplateSchema,

View File

@@ -1,6 +1,7 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type { CreateNewsletterInput } from '../schema/newsletter.schema';
/* eslint-disable @typescript-eslint/no-explicit-any */
@@ -9,7 +10,10 @@ import type { CreateNewsletterInput } from '../schema/newsletter.schema';
* Template variable substitution.
* Replaces {{variable}} placeholders with actual values.
*/
function substituteVariables(template: string, variables: Record<string, string>): string {
function substituteVariables(
template: string,
variables: Record<string, string>,
): string {
let result = template;
for (const [key, value] of Object.entries(variables)) {
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
@@ -23,53 +27,96 @@ export function createNewsletterApi(client: SupabaseClient<Database>) {
return {
// --- Templates ---
async listTemplates(accountId: string) {
const { data, error } = await client.from('newsletter_templates').select('*')
.eq('account_id', accountId).order('name');
const { data, error } = await client
.from('newsletter_templates')
.select('*')
.eq('account_id', accountId)
.order('name');
if (error) throw error;
return data ?? [];
},
async createTemplate(input: { accountId: string; name: string; subject: string; bodyHtml: string; bodyText?: string; variables?: string[] }) {
const { data, error } = await client.from('newsletter_templates').insert({
account_id: input.accountId, name: input.name, subject: input.subject,
body_html: input.bodyHtml, body_text: input.bodyText,
variables: input.variables ?? [],
}).select().single();
async createTemplate(input: {
accountId: string;
name: string;
subject: string;
bodyHtml: string;
bodyText?: string;
variables?: string[];
}) {
const { data, error } = await client
.from('newsletter_templates')
.insert({
account_id: input.accountId,
name: input.name,
subject: input.subject,
body_html: input.bodyHtml,
body_text: input.bodyText,
variables: input.variables ?? [],
})
.select()
.single();
if (error) throw error;
return data;
},
// --- Newsletters ---
async listNewsletters(accountId: string) {
const { data, error } = await client.from('newsletters').select('*')
.eq('account_id', accountId).order('created_at', { ascending: false });
const { data, error } = await client
.from('newsletters')
.select('*')
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (error) throw error;
return data ?? [];
},
async createNewsletter(input: CreateNewsletterInput, userId: string) {
const { data, error } = await client.from('newsletters').insert({
account_id: input.accountId, template_id: input.templateId,
subject: input.subject, body_html: input.bodyHtml, body_text: input.bodyText,
status: input.scheduledAt ? 'scheduled' : 'draft',
scheduled_at: input.scheduledAt, created_by: userId,
}).select().single();
const { data, error } = await client
.from('newsletters')
.insert({
account_id: input.accountId,
template_id: input.templateId,
subject: input.subject,
body_html: input.bodyHtml,
body_text: input.bodyText,
status: input.scheduledAt ? 'scheduled' : 'draft',
scheduled_at: input.scheduledAt,
created_by: userId,
})
.select()
.single();
if (error) throw error;
return data;
},
async getNewsletter(newsletterId: string) {
const { data, error } = await client.from('newsletters').select('*').eq('id', newsletterId).single();
const { data, error } = await client
.from('newsletters')
.select('*')
.eq('id', newsletterId)
.single();
if (error) throw error;
return data;
},
// --- Recipients ---
async addRecipientsFromMembers(newsletterId: string, accountId: string, filter?: { status?: string[]; hasEmail?: boolean }) {
let query = client.from('members').select('id, first_name, last_name, email')
.eq('account_id', accountId).not('email', 'is', null).neq('email', '');
async addRecipientsFromMembers(
newsletterId: string,
accountId: string,
filter?: { status?: string[]; hasEmail?: boolean },
) {
let query = client
.from('members')
.select('id, first_name, last_name, email')
.eq('account_id', accountId)
.not('email', 'is', null)
.neq('email', '');
if (filter?.status && filter.status.length > 0) {
query = query.in('status', filter.status as Database['public']['Enums']['membership_status'][]);
query = query.in(
'status',
filter.status as Database['public']['Enums']['membership_status'][],
);
}
const { data: members, error } = await query;
@@ -84,19 +131,27 @@ export function createNewsletterApi(client: SupabaseClient<Database>) {
}));
if (recipients.length > 0) {
const { error: insertError } = await client.from('newsletter_recipients').insert(recipients);
const { error: insertError } = await client
.from('newsletter_recipients')
.insert(recipients);
if (insertError) throw insertError;
}
// Update newsletter total
await client.from('newsletters').update({ total_recipients: recipients.length }).eq('id', newsletterId);
await client
.from('newsletters')
.update({ total_recipients: recipients.length })
.eq('id', newsletterId);
return recipients.length;
},
async getRecipients(newsletterId: string) {
const { data, error } = await client.from('newsletter_recipients').select('*')
.eq('newsletter_id', newsletterId).order('name');
const { data, error } = await client
.from('newsletter_recipients')
.select('*')
.eq('newsletter_id', newsletterId)
.order('name');
if (error) throw error;
return data ?? [];
},
@@ -112,7 +167,10 @@ export function createNewsletterApi(client: SupabaseClient<Database>) {
const pending = recipients.filter((r: any) => r.status === 'pending');
// Mark as sending
await client.from('newsletters').update({ status: 'sending' }).eq('id', newsletterId);
await client
.from('newsletters')
.update({ status: 'sending' })
.eq('id', newsletterId);
let sentCount = 0;
let failedCount = 0;
@@ -129,25 +187,37 @@ export function createNewsletterApi(client: SupabaseClient<Database>) {
// TODO: Use @kit/mailers to actually send
// await mailer.send({ to: recipient.email, subject: newsletter.subject, html: personalizedHtml });
await client.from('newsletter_recipients').update({
status: 'sent', sent_at: new Date().toISOString(),
}).eq('id', recipient.id);
await client
.from('newsletter_recipients')
.update({
status: 'sent',
sent_at: new Date().toISOString(),
})
.eq('id', recipient.id);
sentCount++;
} catch (err) {
await client.from('newsletter_recipients').update({
status: 'failed', error_message: err instanceof Error ? err.message : 'Unknown error',
}).eq('id', recipient.id);
await client
.from('newsletter_recipients')
.update({
status: 'failed',
error_message:
err instanceof Error ? err.message : 'Unknown error',
})
.eq('id', recipient.id);
failedCount++;
}
}
// Update newsletter totals
await client.from('newsletters').update({
status: failedCount === pending.length ? 'failed' : 'sent',
sent_at: new Date().toISOString(),
sent_count: sentCount,
failed_count: failedCount,
}).eq('id', newsletterId);
await client
.from('newsletters')
.update({
status: failedCount === pending.length ? 'failed' : 'sent',
sent_at: new Date().toISOString(),
sent_count: sentCount,
failed_count: failedCount,
})
.eq('id', newsletterId);
return { sentCount, failedCount };
},

View File

@@ -1,6 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "*.tsx", "src"],
"exclude": ["node_modules"]
}

View File

@@ -1,14 +1,24 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { CreatePageSchema } from '../schema/site.schema';
import { createPage } from '../server/actions/site-builder-actions';
@@ -35,7 +45,10 @@ export function CreatePageForm({ accountId, account }: Props) {
.toLowerCase()
.replace(/[^a-z0-9äöüß\s-]+/g, '')
.replace(/\s+/g, '-')
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/^-|-$/g, '');
const { execute, isPending } = useAction(createPage, {
@@ -45,50 +58,102 @@ export function CreatePageForm({ accountId, account }: Props) {
router.push(`/home/${account}/site-builder/${data.data.id}/edit`);
}
},
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler beim Erstellen'),
onError: ({ error }) =>
toast.error(error.serverError ?? 'Fehler beim Erstellen'),
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute({ ...data, slug: data.slug || autoSlug }))} className="space-y-6 max-w-xl">
<form
onSubmit={form.handleSubmit((data) =>
execute({ ...data, slug: data.slug || autoSlug }),
)}
className="max-w-xl space-y-6"
>
<Card>
<CardHeader><CardTitle>Neue Seite erstellen</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Neue Seite erstellen</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField control={form.control} name="title" render={({ field }) => (
<FormItem>
<FormLabel>Seitentitel *</FormLabel>
<FormControl><Input placeholder="z.B. Startseite, Über uns, Kontakt" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="slug" render={({ field }) => (
<FormItem>
<FormLabel>URL-Pfad</FormLabel>
<FormControl><Input placeholder={autoSlug || 'wird-automatisch-generiert'} {...field} /></FormControl>
<p className="text-xs text-muted-foreground">Leer lassen für automatische Generierung aus dem Titel</p>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="metaDescription" render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung (SEO)</FormLabel>
<FormControl><Input placeholder="Kurze Beschreibung für Suchmaschinen" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="isHomepage" render={({ field }) => (
<FormItem className="flex items-center gap-3">
<FormControl>
<input type="checkbox" checked={field.value} onChange={field.onChange} className="h-4 w-4" />
</FormControl>
<FormLabel className="!mt-0">Als Startseite festlegen</FormLabel>
</FormItem>
)} />
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Seitentitel *</FormLabel>
<FormControl>
<Input
placeholder="z.B. Startseite, Über uns, Kontakt"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>URL-Pfad</FormLabel>
<FormControl>
<Input
placeholder={autoSlug || 'wird-automatisch-generiert'}
{...field}
/>
</FormControl>
<p className="text-muted-foreground text-xs">
Leer lassen für automatische Generierung aus dem Titel
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metaDescription"
render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung (SEO)</FormLabel>
<FormControl>
<Input
placeholder="Kurze Beschreibung für Suchmaschinen"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isHomepage"
render={({ field }) => (
<FormItem className="flex items-center gap-3">
<FormControl>
<input
type="checkbox"
checked={field.value}
onChange={field.onChange}
className="h-4 w-4"
/>
</FormControl>
<FormLabel className="!mt-0">
Als Startseite festlegen
</FormLabel>
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Seite erstellen & Editor öffnen'}</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird erstellt...' : 'Seite erstellen & Editor öffnen'}
</Button>
</div>
</form>
</Form>

View File

@@ -1,14 +1,24 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { CreatePostSchema } from '../schema/site.schema';
import { createPost } from '../server/actions/site-builder-actions';
@@ -34,47 +44,127 @@ export function CreatePostForm({ accountId, account }: Props) {
// Auto-generate slug from title
const watchTitle = form.watch('title');
const autoSlug = watchTitle.toLowerCase().replace(/[^a-z0-9äöüß]+/g, '-').replace(/^-|-$/g, '').replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss');
const autoSlug = watchTitle
.toLowerCase()
.replace(/[^a-z0-9äöüß]+/g, '-')
.replace(/^-|-$/g, '')
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss');
const { execute, isPending } = useAction(createPost, {
onSuccess: () => { toast.success('Beitrag erstellt'); router.push(`/home/${account}/site-builder/posts`); },
onSuccess: () => {
toast.success('Beitrag erstellt');
router.push(`/home/${account}/site-builder/posts`);
},
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute({ ...data, slug: data.slug || autoSlug }))} className="space-y-6 max-w-3xl">
<form
onSubmit={form.handleSubmit((data) =>
execute({ ...data, slug: data.slug || autoSlug }),
)}
className="max-w-3xl space-y-6"
>
<Card>
<CardHeader><CardTitle>Beitrag</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Beitrag</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField control={form.control} name="title" render={({ field }) => (
<FormItem><FormLabel>Titel *</FormLabel><FormControl><Input placeholder="Vereinsnachrichten..." {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="slug" render={({ field }) => (
<FormItem><FormLabel>URL-Slug</FormLabel><FormControl><Input placeholder={autoSlug || 'wird-automatisch-generiert'} {...field} /></FormControl>
<p className="text-xs text-muted-foreground">Leer lassen für automatische Generierung</p><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="excerpt" render={({ field }) => (
<FormItem><FormLabel>Kurzfassung</FormLabel><FormControl><Input placeholder="Kurze Zusammenfassung..." {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="content" render={({ field }) => (
<FormItem><FormLabel>Inhalt</FormLabel><FormControl>
<textarea {...field} rows={12} className="w-full rounded-md border px-3 py-2 text-sm font-mono" placeholder="Beitragsinhalt (HTML erlaubt)..." />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="status" render={({ field }) => (
<FormItem><FormLabel>Status</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="draft">Entwurf</option>
<option value="published">Veröffentlicht</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Titel *</FormLabel>
<FormControl>
<Input placeholder="Vereinsnachrichten..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>URL-Slug</FormLabel>
<FormControl>
<Input
placeholder={autoSlug || 'wird-automatisch-generiert'}
{...field}
/>
</FormControl>
<p className="text-muted-foreground text-xs">
Leer lassen für automatische Generierung
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="excerpt"
render={({ field }) => (
<FormItem>
<FormLabel>Kurzfassung</FormLabel>
<FormControl>
<Input placeholder="Kurze Zusammenfassung..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>Inhalt</FormLabel>
<FormControl>
<textarea
{...field}
rows={12}
className="w-full rounded-md border px-3 py-2 font-mono text-sm"
placeholder="Beitragsinhalt (HTML erlaubt)..."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<FormControl>
<select
{...field}
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value="draft">Entwurf</option>
<option value="published">Veröffentlicht</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Beitrag erstellen'}</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird erstellt...' : 'Beitrag erstellen'}
</Button>
</div>
</form>
</Form>

View File

@@ -1,25 +1,47 @@
'use client';
import { useState } from 'react';
import { createClient } from '@supabase/supabase-js';
import { useRouter } from 'next/navigation';
import type { Provider } from '@supabase/supabase-js';
import { createClient } from '@supabase/supabase-js';
import { Shield, LogIn, AlertCircle } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { OauthProviderLogoImage } from '@kit/ui/oauth-provider-logo-image';
import { Separator } from '@kit/ui/separator';
import { toast } from '@kit/ui/sonner';
import { Shield, LogIn, AlertCircle } from 'lucide-react';
const OAUTH_PROVIDERS: { id: Provider; label: string }[] = [
{ id: 'google', label: 'Google' },
{ id: 'apple', label: 'Apple' },
{ id: 'azure', label: 'Microsoft' },
{ id: 'github', label: 'GitHub' },
];
interface Props {
slug: string;
accountName: string;
}
function getSupabaseClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
);
}
export function PortalLoginForm({ slug, accountName }: Props) {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
const [error, setError] = useState('');
const handleLogin = async (e: React.FormEvent) => {
@@ -28,18 +50,19 @@ export function PortalLoginForm({ slug, accountName }: Props) {
setError('');
try {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
const supabase = getSupabaseClient();
const { data, error: authError } = await supabase.auth.signInWithPassword(
{
email,
password,
},
);
const { data, error: authError } = await supabase.auth.signInWithPassword({
email,
password,
});
if (authError) {
setError('Ungültige Anmeldedaten. Bitte überprüfen Sie E-Mail und Passwort.');
setError(
'Ungültige Anmeldedaten. Bitte überprüfen Sie E-Mail und Passwort.',
);
setLoading(false);
return;
}
@@ -56,19 +79,42 @@ export function PortalLoginForm({ slug, accountName }: Props) {
}
};
const handleOAuthLogin = async (provider: Provider) => {
setOauthLoading(provider);
setError('');
try {
const supabase = getSupabaseClient();
const redirectTo = `${window.location.origin}/club/${slug}/portal`;
const { error: oauthError } = await supabase.auth.signInWithOAuth({
provider,
options: { redirectTo },
});
if (oauthError) {
setError(`Anmeldung fehlgeschlagen: ${oauthError.message}`);
setOauthLoading(null);
}
} catch {
setError('Verbindungsfehler. Bitte versuchen Sie es erneut.');
setOauthLoading(null);
}
};
return (
<Card className="w-full max-w-md mx-auto">
<Card className="mx-auto w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Shield className="h-6 w-6 text-primary" />
<div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
<Shield className="text-primary h-6 w-6" />
</div>
<CardTitle>Mitgliederbereich</CardTitle>
<p className="text-sm text-muted-foreground">{accountName}</p>
<p className="text-muted-foreground text-sm">{accountName}</p>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="bg-destructive/10 text-destructive flex items-center gap-2 rounded-md p-3 text-sm">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
@@ -103,10 +149,39 @@ export function PortalLoginForm({ slug, accountName }: Props) {
</>
)}
</Button>
<p className="text-xs text-center text-muted-foreground">
Zugangsdaten erhalten Sie per Einladung von Ihrem Verein.
</p>
</form>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<Separator />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2">
oder
</span>
</div>
</div>
<div className="flex flex-col gap-2">
{OAUTH_PROVIDERS.map(({ id, label }) => (
<Button
key={id}
variant="outline"
className="w-full gap-2"
disabled={!!oauthLoading}
onClick={() => handleOAuthLogin(id)}
>
<OauthProviderLogoImage providerId={id} />
{oauthLoading === id
? 'Wird weitergeleitet...'
: `Mit ${label} anmelden`}
</Button>
))}
</div>
<p className="text-muted-foreground mt-4 text-center text-xs">
Zugangsdaten erhalten Sie per Einladung von Ihrem Verein.
</p>
</CardContent>
</Card>
);

View File

@@ -1,9 +1,12 @@
'use client';
import { Puck } from '@measured/puck';
import '@measured/puck/puck.css';
import { useAction } from 'next-safe-action/hooks';
import { toast } from '@kit/ui/sonner';
import { clubPuckConfig } from '../config/puck-config';
import { publishPage } from '../server/actions/site-builder-actions';

View File

@@ -1,6 +1,7 @@
'use client';
import { Render } from '@measured/puck';
import { clubPuckConfig } from '../config/puck-config';
import { SiteDataProvider, type SiteData } from '../context/site-data-context';
@@ -10,7 +11,12 @@ interface Props {
}
export function SiteRenderer({ data, siteData }: Props) {
const defaultData: SiteData = { accountId: '', events: [], courses: [], posts: [] };
const defaultData: SiteData = {
accountId: '',
events: [],
courses: [],
posts: [],
};
return (
<SiteDataProvider data={siteData ?? defaultData}>

View File

@@ -1,14 +1,24 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { SiteSettingsSchema } from '../schema/site.schema';
import { updateSiteSettings } from '../server/actions/site-builder-actions';
@@ -41,79 +51,210 @@ export function SiteSettingsForm({ accountId, account, settings }: Props) {
});
const { execute, isPending } = useAction(updateSiteSettings, {
onSuccess: () => { toast.success('Einstellungen gespeichert'); router.refresh(); },
onSuccess: () => {
toast.success('Einstellungen gespeichert');
router.refresh();
},
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6 max-w-3xl">
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="max-w-3xl space-y-6"
>
<Card>
<CardHeader><CardTitle>Allgemein</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Allgemein</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="siteName" render={({ field }) => (
<FormItem><FormLabel>Website-Name</FormLabel><FormControl><Input placeholder="Mein Verein" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="fontFamily" render={({ field }) => (
<FormItem><FormLabel>Schriftart</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="Inter">Inter</option>
<option value="system-ui">System</option>
<option value="Georgia">Georgia</option>
<option value="Roboto">Roboto</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="primaryColor" render={({ field }) => (
<FormItem><FormLabel>Primärfarbe</FormLabel><FormControl><Input type="color" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="secondaryColor" render={({ field }) => (
<FormItem><FormLabel>Sekundärfarbe</FormLabel><FormControl><Input type="color" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="siteName"
render={({ field }) => (
<FormItem>
<FormLabel>Website-Name</FormLabel>
<FormControl>
<Input placeholder="Mein Verein" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fontFamily"
render={({ field }) => (
<FormItem>
<FormLabel>Schriftart</FormLabel>
<FormControl>
<select
{...field}
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value="Inter">Inter</option>
<option value="system-ui">System</option>
<option value="Georgia">Georgia</option>
<option value="Roboto">Roboto</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="primaryColor"
render={({ field }) => (
<FormItem>
<FormLabel>Primärfarbe</FormLabel>
<FormControl>
<Input type="color" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secondaryColor"
render={({ field }) => (
<FormItem>
<FormLabel>Sekundärfarbe</FormLabel>
<FormControl>
<Input type="color" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Kontakt</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Kontakt</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="contactEmail" render={({ field }) => (
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="contactPhone" render={({ field }) => (
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="contactAddress" render={({ field }) => (
<FormItem className="col-span-full"><FormLabel>Adresse</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="contactEmail"
render={({ field }) => (
<FormItem>
<FormLabel>E-Mail</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contactPhone"
render={({ field }) => (
<FormItem>
<FormLabel>Telefon</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contactAddress"
render={({ field }) => (
<FormItem className="col-span-full">
<FormLabel>Adresse</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Rechtliches</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Rechtliches</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField control={form.control} name="impressum" render={({ field }) => (
<FormItem><FormLabel>Impressum</FormLabel><FormControl><textarea {...field} rows={6} className="w-full rounded-md border px-3 py-2 text-sm" /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="datenschutz" render={({ field }) => (
<FormItem><FormLabel>Datenschutzerklärung</FormLabel><FormControl><textarea {...field} rows={6} className="w-full rounded-md border px-3 py-2 text-sm" /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="impressum"
render={({ field }) => (
<FormItem>
<FormLabel>Impressum</FormLabel>
<FormControl>
<textarea
{...field}
rows={6}
className="w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="datenschutz"
render={({ field }) => (
<FormItem>
<FormLabel>Datenschutzerklärung</FormLabel>
<FormControl>
<textarea
{...field}
rows={6}
className="w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Veröffentlichung</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Veröffentlichung</CardTitle>
</CardHeader>
<CardContent>
<FormField control={form.control} name="isPublic" render={({ field }) => (
<FormItem className="flex items-center gap-3">
<FormControl><input type="checkbox" checked={field.value} onChange={field.onChange} className="h-4 w-4" /></FormControl>
<div>
<FormLabel>Website öffentlich zugänglich</FormLabel>
<p className="text-xs text-muted-foreground">Wenn aktiviert, ist Ihre Website unter /club/{account} erreichbar.</p>
</div>
</FormItem>
)} />
<FormField
control={form.control}
name="isPublic"
render={({ field }) => (
<FormItem className="flex items-center gap-3">
<FormControl>
<input
type="checkbox"
checked={field.value}
onChange={field.onChange}
className="h-4 w-4"
/>
</FormControl>
<div>
<FormLabel>Website öffentlich zugänglich</FormLabel>
<p className="text-muted-foreground text-xs">
Wenn aktiviert, ist Ihre Website unter /club/{account}{' '}
erreichbar.
</p>
</div>
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird gespeichert...' : 'Einstellungen speichern'}</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird gespeichert...' : 'Einstellungen speichern'}
</Button>
</div>
</form>
</Form>

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,11 @@ import { z } from 'zod';
export const CreatePageSchema = z.object({
accountId: z.string().uuid(),
slug: z.string().min(1).max(128).regex(/^[a-z0-9-]+$/),
slug: z
.string()
.min(1)
.max(128)
.regex(/^[a-z0-9-]+$/),
title: z.string().min(1).max(256),
puckData: z.record(z.string(), z.unknown()).default({}),
isHomepage: z.boolean().default(false),
@@ -29,7 +33,9 @@ export const SiteSettingsSchema = z.object({
secondaryColor: z.string().default('#64748b'),
fontFamily: z.string().default('Inter'),
customCss: z.string().optional(),
navigation: z.array(z.object({ label: z.string(), href: z.string() })).default([]),
navigation: z
.array(z.object({ label: z.string(), href: z.string() }))
.default([]),
footerText: z.string().optional(),
contactEmail: z.string().optional(),
contactPhone: z.string().optional(),
@@ -42,7 +48,10 @@ export const SiteSettingsSchema = z.object({
export const CreatePostSchema = z.object({
accountId: z.string().uuid(),
title: z.string().min(1),
slug: z.string().min(1).regex(/^[a-z0-9-]+$/),
slug: z
.string()
.min(1)
.regex(/^[a-z0-9-]+$/),
content: z.string().optional(),
excerpt: z.string().optional(),
coverImage: z.string().optional(),

View File

@@ -1,10 +1,19 @@
'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreatePageSchema, UpdatePageSchema, SiteSettingsSchema, CreatePostSchema, UpdatePostSchema, NewsletterSubscribeSchema } from '../../schema/site.schema';
import {
CreatePageSchema,
UpdatePageSchema,
SiteSettingsSchema,
CreatePostSchema,
UpdatePostSchema,
NewsletterSubscribeSchema,
} from '../../schema/site.schema';
import { createSiteBuilderApi } from '../api';
export const createPage = authActionClient
@@ -21,7 +30,11 @@ export const saveDraft = authActionClient
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const api = createSiteBuilderApi(client);
const data = await api.updatePage(input.pageId, { ...input, isPublished: false }, ctx.user.id);
const data = await api.updatePage(
input.pageId,
{ ...input, isPublished: false },
ctx.user.id,
);
return { success: true, data };
});
@@ -30,7 +43,11 @@ export const publishPage = authActionClient
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const api = createSiteBuilderApi(client);
const data = await api.updatePage(input.pageId, { ...input, isPublished: true }, ctx.user.id);
const data = await api.updatePage(
input.pageId,
{ ...input, isPublished: true },
ctx.user.id,
);
return { success: true, data };
});

View File

@@ -1,42 +1,90 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createSiteBuilderApi(client: SupabaseClient<Database>) {
return {
// Pages
async listPages(accountId: string) {
const { data, error } = await client.from('site_pages').select('*')
.eq('account_id', accountId).order('sort_order');
const { data, error } = await client
.from('site_pages')
.select('*')
.eq('account_id', accountId)
.order('sort_order');
if (error) throw error;
return data ?? [];
},
async getPage(pageId: string) {
const { data, error } = await client.from('site_pages').select('*').eq('id', pageId).single();
const { data, error } = await client
.from('site_pages')
.select('*')
.eq('id', pageId)
.single();
if (error) throw error;
return data;
},
async getPageBySlug(accountId: string, slug: string) {
const { data, error } = await client.from('site_pages').select('*')
.eq('account_id', accountId).eq('slug', slug).single();
const { data, error } = await client
.from('site_pages')
.select('*')
.eq('account_id', accountId)
.eq('slug', slug)
.single();
if (error) throw error;
return data;
},
async getHomepage(accountId: string) {
const { data, error } = await client.from('site_pages').select('*')
.eq('account_id', accountId).eq('is_homepage', true).eq('is_published', true).maybeSingle();
const { data, error } = await client
.from('site_pages')
.select('*')
.eq('account_id', accountId)
.eq('is_homepage', true)
.eq('is_published', true)
.maybeSingle();
if (error) throw error;
return data;
},
async createPage(input: { accountId: string; slug: string; title: string; puckData?: Record<string, unknown>; isHomepage?: boolean; metaDescription?: string }, userId: string) {
const { data, error } = await client.from('site_pages').insert({
account_id: input.accountId, slug: input.slug, title: input.title,
puck_data: (input.puckData ?? {}) as any, is_homepage: input.isHomepage ?? false,
meta_description: input.metaDescription, created_by: userId, updated_by: userId,
}).select().single();
async createPage(
input: {
accountId: string;
slug: string;
title: string;
puckData?: Record<string, unknown>;
isHomepage?: boolean;
metaDescription?: string;
},
userId: string,
) {
const { data, error } = await client
.from('site_pages')
.insert({
account_id: input.accountId,
slug: input.slug,
title: input.title,
puck_data: (input.puckData ?? {}) as any,
is_homepage: input.isHomepage ?? false,
meta_description: input.metaDescription,
created_by: userId,
updated_by: userId,
})
.select()
.single();
if (error) throw error;
return data;
},
async updatePage(pageId: string, input: { title?: string; slug?: string; puckData?: Record<string, unknown>; isPublished?: boolean; isHomepage?: boolean; metaDescription?: string; metaImage?: string }, userId: string) {
async updatePage(
pageId: string,
input: {
title?: string;
slug?: string;
puckData?: Record<string, unknown>;
isPublished?: boolean;
isHomepage?: boolean;
metaDescription?: string;
metaImage?: string;
},
userId: string,
) {
const update: Record<string, unknown> = { updated_by: userId };
if (input.title !== undefined) update.title = input.title;
if (input.slug !== undefined) update.slug = input.slug;
@@ -46,65 +94,129 @@ export function createSiteBuilderApi(client: SupabaseClient<Database>) {
if (input.isPublished) update.published_at = new Date().toISOString();
}
if (input.isHomepage !== undefined) update.is_homepage = input.isHomepage;
if (input.metaDescription !== undefined) update.meta_description = input.metaDescription;
if (input.metaDescription !== undefined)
update.meta_description = input.metaDescription;
if (input.metaImage !== undefined) update.meta_image = input.metaImage;
const { data, error } = await client.from('site_pages').update(update).eq('id', pageId).select().single();
const { data, error } = await client
.from('site_pages')
.update(update)
.eq('id', pageId)
.select()
.single();
if (error) throw error;
return data;
},
async deletePage(pageId: string) {
const { error } = await client.from('site_pages').delete().eq('id', pageId);
const { error } = await client
.from('site_pages')
.delete()
.eq('id', pageId);
if (error) throw error;
},
// Settings
async getSiteSettings(accountId: string) {
const { data, error } = await client.from('site_settings').select('*').eq('account_id', accountId).maybeSingle();
const { data, error } = await client
.from('site_settings')
.select('*')
.eq('account_id', accountId)
.maybeSingle();
if (error) throw error;
return data;
},
async upsertSiteSettings(accountId: string, input: Record<string, unknown>) {
async upsertSiteSettings(
accountId: string,
input: Record<string, unknown>,
) {
const row: Record<string, unknown> = { account_id: accountId };
if (input.siteName !== undefined) row.site_name = input.siteName;
if (input.siteLogo !== undefined) row.site_logo = input.siteLogo;
if (input.primaryColor !== undefined) row.primary_color = input.primaryColor;
if (input.secondaryColor !== undefined) row.secondary_color = input.secondaryColor;
if (input.primaryColor !== undefined)
row.primary_color = input.primaryColor;
if (input.secondaryColor !== undefined)
row.secondary_color = input.secondaryColor;
if (input.fontFamily !== undefined) row.font_family = input.fontFamily;
if (input.navigation !== undefined) row.navigation = input.navigation;
if (input.footerText !== undefined) row.footer_text = input.footerText;
if (input.contactEmail !== undefined) row.contact_email = input.contactEmail;
if (input.contactPhone !== undefined) row.contact_phone = input.contactPhone;
if (input.contactAddress !== undefined) row.contact_address = input.contactAddress;
if (input.contactEmail !== undefined)
row.contact_email = input.contactEmail;
if (input.contactPhone !== undefined)
row.contact_phone = input.contactPhone;
if (input.contactAddress !== undefined)
row.contact_address = input.contactAddress;
if (input.impressum !== undefined) row.impressum = input.impressum;
if (input.datenschutz !== undefined) row.datenschutz = input.datenschutz;
if (input.isPublic !== undefined) row.is_public = input.isPublic;
const { data, error } = await client.from('site_settings').upsert(row as any).select().single();
const { data, error } = await client
.from('site_settings')
.upsert(row as any)
.select()
.single();
if (error) throw error;
return data;
},
// Posts
async listPosts(accountId: string, status?: string) {
let query = client.from('cms_posts').select('*').eq('account_id', accountId).order('created_at', { ascending: false });
let query = client
.from('cms_posts')
.select('*')
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (status) query = query.eq('status', status);
const { data, error } = await query;
if (error) throw error;
return data ?? [];
},
async getPost(postId: string) {
const { data, error } = await client.from('cms_posts').select('*').eq('id', postId).single();
const { data, error } = await client
.from('cms_posts')
.select('*')
.eq('id', postId)
.single();
if (error) throw error;
return data;
},
async createPost(input: { accountId: string; title: string; slug: string; content?: string; excerpt?: string; coverImage?: string; status?: string }, userId: string) {
const { data, error } = await client.from('cms_posts').insert({
account_id: input.accountId, title: input.title, slug: input.slug,
content: input.content, excerpt: input.excerpt, cover_image: input.coverImage,
status: input.status ?? 'draft', author_id: userId,
published_at: input.status === 'published' ? new Date().toISOString() : null,
}).select().single();
async createPost(
input: {
accountId: string;
title: string;
slug: string;
content?: string;
excerpt?: string;
coverImage?: string;
status?: string;
},
userId: string,
) {
const { data, error } = await client
.from('cms_posts')
.insert({
account_id: input.accountId,
title: input.title,
slug: input.slug,
content: input.content,
excerpt: input.excerpt,
cover_image: input.coverImage,
status: input.status ?? 'draft',
author_id: userId,
published_at:
input.status === 'published' ? new Date().toISOString() : null,
})
.select()
.single();
if (error) throw error;
return data;
},
async updatePost(postId: string, input: { title?: string; slug?: string; content?: string; excerpt?: string; coverImage?: string; status?: string }) {
async updatePost(
postId: string,
input: {
title?: string;
slug?: string;
content?: string;
excerpt?: string;
coverImage?: string;
status?: string;
},
) {
const update: Record<string, unknown> = {};
if (input.title !== undefined) update.title = input.title;
if (input.slug !== undefined) update.slug = input.slug;
@@ -113,22 +225,38 @@ export function createSiteBuilderApi(client: SupabaseClient<Database>) {
if (input.coverImage !== undefined) update.cover_image = input.coverImage;
if (input.status !== undefined) {
update.status = input.status;
if (input.status === 'published') update.published_at = new Date().toISOString();
if (input.status === 'published')
update.published_at = new Date().toISOString();
}
const { data, error } = await client.from('cms_posts').update(update).eq('id', postId).select().single();
const { data, error } = await client
.from('cms_posts')
.update(update)
.eq('id', postId)
.select()
.single();
if (error) throw error;
return data;
},
async deletePost(postId: string) {
const { error } = await client.from('cms_posts').delete().eq('id', postId);
const { error } = await client
.from('cms_posts')
.delete()
.eq('id', postId);
if (error) throw error;
},
// Newsletter
async subscribe(accountId: string, email: string, name?: string) {
const token = crypto.randomUUID();
const { error } = await client.from('newsletter_subscriptions').upsert({
account_id: accountId, email, name, confirmation_token: token, is_active: true,
}, { onConflict: 'account_id,email' });
const { error } = await client.from('newsletter_subscriptions').upsert(
{
account_id: accountId,
email,
name,
confirmation_token: token,
is_active: true,
},
{ onConflict: 'account_id,email' },
);
if (error) throw error;
return token;
},

View File

@@ -1,6 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "*.tsx", "src"],
"exclude": ["node_modules"]
}

View File

@@ -1,12 +1,14 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { todayISO } from '@kit/shared/dates';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
@@ -16,7 +18,7 @@ import {
FormMessage,
FormDescription,
} from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { Switch } from '@kit/ui/switch';
@@ -39,7 +41,7 @@ export function CreateProtocolForm({
defaultValues: {
accountId,
title: '',
meetingDate: new Date().toISOString().split('T')[0]!,
meetingDate: todayISO(),
meetingType: 'vorstand' as const,
location: '',
attendees: '',
@@ -80,7 +82,10 @@ export function CreateProtocolForm({
<FormItem>
<FormLabel>Titel *</FormLabel>
<FormControl>
<Input placeholder="z.B. Vorstandssitzung März 2026" {...field} />
<Input
placeholder="z.B. Vorstandssitzung März 2026"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -111,7 +116,7 @@ export function CreateProtocolForm({
<FormControl>
<select
{...field}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value="vorstand">Vorstandssitzung</option>
<option value="mitglieder">Mitgliederversammlung</option>
@@ -156,7 +161,7 @@ export function CreateProtocolForm({
<textarea
{...field}
placeholder="Namen der Teilnehmer (kommagetrennt oder zeilenweise)"
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
@@ -174,7 +179,7 @@ export function CreateProtocolForm({
<textarea
{...field}
placeholder="Optionale Anmerkungen zum Protokoll"
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
@@ -196,9 +201,12 @@ export function CreateProtocolForm({
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Protokoll veröffentlichen</FormLabel>
<FormLabel className="text-base">
Protokoll veröffentlichen
</FormLabel>
<FormDescription>
Veröffentlichte Protokolle sind für alle Mitglieder sichtbar.
Veröffentlichte Protokolle sind für alle Mitglieder
sichtbar.
</FormDescription>
</div>
<FormControl>

View File

@@ -2,13 +2,9 @@
import Link from 'next/link';
import {
FileText,
Calendar,
ListChecks,
AlertTriangle,
} from 'lucide-react';
import { FileText, Calendar, ListChecks, AlertTriangle } from 'lucide-react';
import { formatDate } from '@kit/shared/dates';
import { Badge } from '@kit/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
@@ -71,10 +67,12 @@ export function MeetingsDashboard({
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Protokolle gesamt</p>
<p className="text-muted-foreground text-sm font-medium">
Protokolle gesamt
</p>
<p className="text-2xl font-bold">{stats.totalProtocols}</p>
</div>
<div className="rounded-full bg-primary/10 p-3 text-primary">
<div className="bg-primary/10 text-primary rounded-full p-3">
<FileText className="h-5 w-5" />
</div>
</div>
@@ -87,10 +85,14 @@ export function MeetingsDashboard({
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Protokolle dieses Jahr</p>
<p className="text-2xl font-bold">{stats.thisYearProtocols}</p>
<p className="text-muted-foreground text-sm font-medium">
Protokolle dieses Jahr
</p>
<p className="text-2xl font-bold">
{stats.thisYearProtocols}
</p>
</div>
<div className="rounded-full bg-primary/10 p-3 text-primary">
<div className="bg-primary/10 text-primary rounded-full p-3">
<Calendar className="h-5 w-5" />
</div>
</div>
@@ -103,10 +105,12 @@ export function MeetingsDashboard({
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Offene Aufgaben</p>
<p className="text-muted-foreground text-sm font-medium">
Offene Aufgaben
</p>
<p className="text-2xl font-bold">{stats.openTasks}</p>
</div>
<div className="rounded-full bg-primary/10 p-3 text-primary">
<div className="bg-primary/10 text-primary rounded-full p-3">
<ListChecks className="h-5 w-5" />
</div>
</div>
@@ -119,10 +123,12 @@ export function MeetingsDashboard({
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Überfällige Aufgaben</p>
<p className="text-muted-foreground text-sm font-medium">
Überfällige Aufgaben
</p>
<p className="text-2xl font-bold">{stats.overdueTasks}</p>
</div>
<div className="rounded-full bg-destructive/10 p-3 text-destructive">
<div className="bg-destructive/10 text-destructive rounded-full p-3">
<AlertTriangle className="h-5 w-5" />
</div>
</div>
@@ -139,7 +145,7 @@ export function MeetingsDashboard({
</CardHeader>
<CardContent>
{recentProtocols.length === 0 ? (
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Noch keine Protokolle vorhanden.
</p>
) : (
@@ -148,18 +154,23 @@ export function MeetingsDashboard({
<Link
key={protocol.id}
href={`/home/${account}/meetings/protocols/${protocol.id}`}
className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50"
className="hover:bg-muted/50 flex items-center justify-between rounded-lg border p-3 transition-colors"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{protocol.title}</p>
<p className="text-xs text-muted-foreground">
{new Date(protocol.meeting_date).toLocaleDateString('de-DE')}
<p className="truncate text-sm font-medium">
{protocol.title}
</p>
<p className="text-muted-foreground text-xs">
{formatDate(protocol.meeting_date)}
{' · '}
{MEETING_TYPE_LABELS[protocol.meeting_type] ?? protocol.meeting_type}
{MEETING_TYPE_LABELS[protocol.meeting_type] ??
protocol.meeting_type}
</p>
</div>
{protocol.is_published && (
<Badge variant="default" className="ml-2 shrink-0">Veröffentlicht</Badge>
<Badge variant="default" className="ml-2 shrink-0">
Veröffentlicht
</Badge>
)}
</Link>
))}
@@ -174,7 +185,7 @@ export function MeetingsDashboard({
</CardHeader>
<CardContent>
{overdueTasks.length === 0 ? (
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Keine überfälligen Aufgaben.
</p>
) : (
@@ -182,20 +193,18 @@ export function MeetingsDashboard({
{overdueTasks.map((task) => (
<div
key={task.id}
className="rounded-lg border border-destructive/20 bg-destructive/5 p-3"
className="border-destructive/20 bg-destructive/5 rounded-lg border p-3"
>
<p className="text-sm font-medium">{task.title}</p>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
{task.responsible_person && (
<span>Zuständig: {task.responsible_person}</span>
)}
{task.due_date && (
<span>
Fällig: {new Date(task.due_date).toLocaleDateString('de-DE')}
</span>
<span>Fällig: {formatDate(task.due_date)}</span>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground">
<p className="text-muted-foreground mt-1 text-xs">
Protokoll: {task.meeting_protocols.title}
</p>
</div>

View File

@@ -1,6 +1,7 @@
'use client';
import Link from 'next/link';
import { cn } from '@kit/ui/utils';
interface MeetingsTabNavigationProps {
@@ -22,7 +23,10 @@ export function MeetingsTabNavigation({
return (
<div className="mb-6 border-b">
<nav className="-mb-px flex space-x-1 overflow-x-auto" aria-label="Sitzungsprotokolle Navigation">
<nav
className="-mb-px flex space-x-1 overflow-x-auto"
aria-label="Sitzungsprotokolle Navigation"
>
{TABS.map((tab) => {
const isActive = tab.id === activeTab;
@@ -31,10 +35,10 @@ export function MeetingsTabNavigation({
key={tab.id}
href={`${basePath}${tab.path}`}
className={cn(
'whitespace-nowrap border-b-2 px-4 py-2.5 text-sm font-medium transition-colors',
'border-b-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors',
isActive
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:border-muted-foreground/30 hover:text-foreground',
: 'text-muted-foreground hover:border-muted-foreground/30 hover:text-foreground border-transparent',
)}
>
{tab.label}

View File

@@ -1,13 +1,18 @@
'use client';
import { useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { formatDate } from '@kit/shared/dates';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { ITEM_STATUS_LABELS, ITEM_STATUS_COLORS } from '../lib/meetings-constants';
import {
ITEM_STATUS_LABELS,
ITEM_STATUS_COLORS,
} from '../lib/meetings-constants';
interface OpenTask {
id: string;
@@ -78,7 +83,7 @@ export function OpenTasksView({
{data.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">Keine offenen Aufgaben</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
Alle Tagesordnungspunkte sind erledigt oder vertagt.
</p>
</div>
@@ -86,7 +91,7 @@ export function OpenTasksView({
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Aufgabe</th>
<th className="p-3 text-left font-medium">Protokoll</th>
<th className="p-3 text-left font-medium">Zuständig</th>
@@ -102,7 +107,7 @@ export function OpenTasksView({
return (
<tr
key={task.id}
className="cursor-pointer border-b hover:bg-muted/30"
className="hover:bg-muted/30 cursor-pointer border-b"
onClick={() =>
router.push(
`/home/${account}/meetings/protocols/${task.meeting_protocols.id}`,
@@ -113,27 +118,29 @@ export function OpenTasksView({
<div>
<p className="font-medium">{task.title}</p>
{task.description && (
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">
<p className="text-muted-foreground mt-0.5 line-clamp-1 text-xs">
{task.description}
</p>
)}
</div>
</td>
<td className="p-3 text-muted-foreground">
<td className="text-muted-foreground p-3">
<div>
<p className="text-sm">{task.meeting_protocols.title}</p>
<p className="text-sm">
{task.meeting_protocols.title}
</p>
<p className="text-xs">
{new Date(task.meeting_protocols.meeting_date).toLocaleDateString('de-DE')}
{formatDate(task.meeting_protocols.meeting_date)}
</p>
</div>
</td>
<td className="p-3 text-muted-foreground">
<td className="text-muted-foreground p-3">
{task.responsible_person ?? '—'}
</td>
<td className={`p-3 ${isOverdue ? 'font-medium text-destructive' : 'text-muted-foreground'}`}>
{task.due_date
? new Date(task.due_date).toLocaleDateString('de-DE')
: '—'}
<td
className={`p-3 ${isOverdue ? 'text-destructive font-medium' : 'text-muted-foreground'}`}
>
{task.due_date ? formatDate(task.due_date) : '—'}
{isOverdue && (
<span className="ml-1 text-xs">(überfällig)</span>
)}
@@ -141,8 +148,11 @@ export function OpenTasksView({
<td className="p-3 text-center">
<Badge
variant={
(ITEM_STATUS_COLORS[task.status] as 'default' | 'secondary' | 'destructive' | 'outline') ??
'outline'
(ITEM_STATUS_COLORS[task.status] as
| 'default'
| 'secondary'
| 'destructive'
| 'outline') ?? 'outline'
}
>
{ITEM_STATUS_LABELS[task.status] ?? task.status}
@@ -159,7 +169,7 @@ export function OpenTasksView({
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Seite {page} von {totalPages} ({total} Einträge)
</p>
<div className="flex gap-2">

View File

@@ -2,15 +2,21 @@
import { useAction } from 'next-safe-action/hooks';
import { formatDate } from '@kit/shared/dates';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { ITEM_STATUS_LABELS, ITEM_STATUS_COLORS } from '../lib/meetings-constants';
import { updateItemStatus, deleteProtocolItem } from '../server/actions/meetings-actions';
import {
ITEM_STATUS_LABELS,
ITEM_STATUS_COLORS,
} from '../lib/meetings-constants';
import type { MeetingItemStatus } from '../schema/meetings.schema';
import {
updateItemStatus,
deleteProtocolItem,
} from '../server/actions/meetings-actions';
interface ProtocolItem {
id: string;
@@ -40,27 +46,33 @@ export function ProtocolItemsList({
protocolId,
account,
}: ProtocolItemsListProps) {
const { execute: executeStatusUpdate, isPending: isUpdating } = useAction(updateItemStatus, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Status aktualisiert');
}
const { execute: executeStatusUpdate, isPending: isUpdating } = useAction(
updateItemStatus,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Status aktualisiert');
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Aktualisieren');
},
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Aktualisieren');
},
});
);
const { execute: executeDelete, isPending: isDeleting } = useAction(deleteProtocolItem, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Tagesordnungspunkt gelöscht');
}
const { execute: executeDelete, isPending: isDeleting } = useAction(
deleteProtocolItem,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Tagesordnungspunkt gelöscht');
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Löschen');
},
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Löschen');
},
});
);
const handleToggleStatus = (item: ProtocolItem) => {
const nextStatus = STATUS_TRANSITIONS[item.status] ?? 'offen';
@@ -82,7 +94,7 @@ export function ProtocolItemsList({
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
<h3 className="text-lg font-semibold">Keine Tagesordnungspunkte</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
Fügen Sie Tagesordnungspunkte zu diesem Protokoll hinzu.
</p>
</div>
@@ -90,7 +102,7 @@ export function ProtocolItemsList({
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">#</th>
<th className="p-3 text-left font-medium">Titel</th>
<th className="p-3 text-left font-medium">Zuständig</th>
@@ -107,31 +119,34 @@ export function ProtocolItemsList({
new Date(item.due_date) < new Date();
return (
<tr key={item.id} className="border-b hover:bg-muted/30">
<td className="p-3 text-muted-foreground">{index + 1}</td>
<tr key={item.id} className="hover:bg-muted/30 border-b">
<td className="text-muted-foreground p-3">{index + 1}</td>
<td className="p-3">
<div>
<p className="font-medium">{item.title}</p>
{item.description && (
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">
<p className="text-muted-foreground mt-0.5 line-clamp-1 text-xs">
{item.description}
</p>
)}
</div>
</td>
<td className="p-3 text-muted-foreground">
<td className="text-muted-foreground p-3">
{item.responsible_person ?? '—'}
</td>
<td className={`p-3 ${isOverdue ? 'font-medium text-destructive' : 'text-muted-foreground'}`}>
{item.due_date
? new Date(item.due_date).toLocaleDateString('de-DE')
: '—'}
<td
className={`p-3 ${isOverdue ? 'text-destructive font-medium' : 'text-muted-foreground'}`}
>
{formatDate(item.due_date)}
</td>
<td className="p-3 text-center">
<Badge
variant={
(ITEM_STATUS_COLORS[item.status] as 'default' | 'secondary' | 'destructive' | 'outline') ??
'outline'
(ITEM_STATUS_COLORS[item.status] as
| 'default'
| 'secondary'
| 'destructive'
| 'outline') ?? 'outline'
}
className="cursor-pointer"
onClick={() => handleToggleStatus(item)}

View File

@@ -1,12 +1,14 @@
'use client';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Plus } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { formatDate } from '@kit/shared/dates';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
@@ -107,7 +109,7 @@ export function ProtocolsDataTable({
<select
value={currentType}
onChange={handleTypeChange}
className="flex h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
className="border-input bg-background flex h-9 rounded-md border px-3 py-1 text-sm shadow-sm"
>
{MEETING_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
@@ -133,11 +135,16 @@ export function ProtocolsDataTable({
<CardContent>
{data.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">Keine Protokolle vorhanden</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
<h3 className="text-lg font-semibold">
Keine Protokolle vorhanden
</h3>
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
Erstellen Sie Ihr erstes Sitzungsprotokoll, um loszulegen.
</p>
<Link href={`/home/${account}/meetings/protocols/new`} className="mt-4">
<Link
href={`/home/${account}/meetings/protocols/new`}
className="mt-4"
>
<Button>Neues Protokoll</Button>
</Link>
</div>
@@ -145,7 +152,7 @@ export function ProtocolsDataTable({
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Datum</th>
<th className="p-3 text-left font-medium">Titel</th>
<th className="p-3 text-left font-medium">Sitzungsart</th>
@@ -157,17 +164,15 @@ export function ProtocolsDataTable({
{data.map((protocol) => (
<tr
key={String(protocol.id)}
className="cursor-pointer border-b hover:bg-muted/30"
className="hover:bg-muted/30 cursor-pointer border-b"
onClick={() =>
router.push(
`/home/${account}/meetings/protocols/${String(protocol.id)}`,
)
}
>
<td className="p-3 text-muted-foreground">
{protocol.meeting_date
? new Date(String(protocol.meeting_date)).toLocaleDateString('de-DE')
: '—'}
<td className="text-muted-foreground p-3">
{formatDate(protocol.meeting_date as string)}
</td>
<td className="p-3 font-medium">
<Link
@@ -183,7 +188,7 @@ export function ProtocolsDataTable({
String(protocol.meeting_type)}
</Badge>
</td>
<td className="p-3 text-muted-foreground">
<td className="text-muted-foreground p-3">
{String(protocol.location ?? '—')}
</td>
<td className="p-3 text-center">
@@ -203,7 +208,7 @@ export function ProtocolsDataTable({
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Seite {page} von {totalPages} ({total} Einträge)
</p>
<div className="flex gap-2">

View File

@@ -37,7 +37,9 @@ export const CreateMeetingProtocolSchema = z.object({
isPublished: z.boolean().default(false),
});
export type CreateMeetingProtocolInput = z.infer<typeof CreateMeetingProtocolSchema>;
export type CreateMeetingProtocolInput = z.infer<
typeof CreateMeetingProtocolSchema
>;
export const UpdateMeetingProtocolSchema = z.object({
protocolId: z.string().uuid(),
@@ -50,7 +52,9 @@ export const UpdateMeetingProtocolSchema = z.object({
isPublished: z.boolean().optional(),
});
export type UpdateMeetingProtocolInput = z.infer<typeof UpdateMeetingProtocolSchema>;
export type UpdateMeetingProtocolInput = z.infer<
typeof UpdateMeetingProtocolSchema
>;
// =====================================================
// Protocol Item Schemas

View File

@@ -1,7 +1,9 @@
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
@@ -14,7 +16,6 @@ import {
UpdateItemStatusSchema,
ReorderItemsSchema,
} from '../../schema/meetings.schema';
import { createMeetingsApi } from '../api';
const REVALIDATION_PATH = '/home/[account]/meetings';
@@ -31,7 +32,10 @@ export const createProtocol = authActionClient
const api = createMeetingsApi(client);
const userId = ctx.user.id;
logger.info({ name: 'meetings.protocol.create' }, 'Protokoll wird erstellt...');
logger.info(
{ name: 'meetings.protocol.create' },
'Protokoll wird erstellt...',
);
const result = await api.createProtocol(input, userId);
logger.info({ name: 'meetings.protocol.create' }, 'Protokoll erstellt');
revalidatePath(REVALIDATION_PATH, 'page');
@@ -46,7 +50,10 @@ export const updateProtocol = authActionClient
const api = createMeetingsApi(client);
const userId = ctx.user.id;
logger.info({ name: 'meetings.protocol.update' }, 'Protokoll wird aktualisiert...');
logger.info(
{ name: 'meetings.protocol.update' },
'Protokoll wird aktualisiert...',
);
const result = await api.updateProtocol(input, userId);
logger.info({ name: 'meetings.protocol.update' }, 'Protokoll aktualisiert');
revalidatePath(REVALIDATION_PATH, 'page');
@@ -65,7 +72,10 @@ export const deleteProtocol = authActionClient
const logger = await getLogger();
const api = createMeetingsApi(client);
logger.info({ name: 'meetings.protocol.delete' }, 'Protokoll wird gelöscht...');
logger.info(
{ name: 'meetings.protocol.delete' },
'Protokoll wird gelöscht...',
);
await api.deleteProtocol(input.protocolId);
logger.info({ name: 'meetings.protocol.delete' }, 'Protokoll gelöscht');
revalidatePath(REVALIDATION_PATH, 'page');
@@ -84,9 +94,15 @@ export const createProtocolItem = authActionClient
const api = createMeetingsApi(client);
const userId = ctx.user.id;
logger.info({ name: 'meetings.item.create' }, 'Tagesordnungspunkt wird erstellt...');
logger.info(
{ name: 'meetings.item.create' },
'Tagesordnungspunkt wird erstellt...',
);
const result = await api.createItem(input, userId);
logger.info({ name: 'meetings.item.create' }, 'Tagesordnungspunkt erstellt');
logger.info(
{ name: 'meetings.item.create' },
'Tagesordnungspunkt erstellt',
);
revalidatePath(REVALIDATION_PATH, 'page');
return { success: true, data: result };
});
@@ -99,9 +115,15 @@ export const updateProtocolItem = authActionClient
const api = createMeetingsApi(client);
const userId = ctx.user.id;
logger.info({ name: 'meetings.item.update' }, 'Tagesordnungspunkt wird aktualisiert...');
logger.info(
{ name: 'meetings.item.update' },
'Tagesordnungspunkt wird aktualisiert...',
);
const result = await api.updateItem(input, userId);
logger.info({ name: 'meetings.item.update' }, 'Tagesordnungspunkt aktualisiert');
logger.info(
{ name: 'meetings.item.update' },
'Tagesordnungspunkt aktualisiert',
);
revalidatePath(REVALIDATION_PATH, 'page');
return { success: true, data: result };
});
@@ -132,9 +154,15 @@ export const deleteProtocolItem = authActionClient
const logger = await getLogger();
const api = createMeetingsApi(client);
logger.info({ name: 'meetings.item.delete' }, 'Tagesordnungspunkt wird gelöscht...');
logger.info(
{ name: 'meetings.item.delete' },
'Tagesordnungspunkt wird gelöscht...',
);
await api.deleteItem(input.itemId);
logger.info({ name: 'meetings.item.delete' }, 'Tagesordnungspunkt gelöscht');
logger.info(
{ name: 'meetings.item.delete' },
'Tagesordnungspunkt gelöscht',
);
revalidatePath(REVALIDATION_PATH, 'page');
return { success: true };
});
@@ -146,7 +174,10 @@ export const reorderProtocolItems = authActionClient
const logger = await getLogger();
const api = createMeetingsApi(client);
logger.info({ name: 'meetings.items.reorder' }, 'Reihenfolge wird aktualisiert...');
logger.info(
{ name: 'meetings.items.reorder' },
'Reihenfolge wird aktualisiert...',
);
await api.reorderItems(input);
logger.info({ name: 'meetings.items.reorder' }, 'Reihenfolge aktualisiert');
revalidatePath(REVALIDATION_PATH, 'page');
@@ -173,7 +204,10 @@ export const addProtocolAttachment = authActionClient
const api = createMeetingsApi(client);
const userId = ctx.user.id;
logger.info({ name: 'meetings.attachment.add' }, 'Anhang wird hinzugefügt...');
logger.info(
{ name: 'meetings.attachment.add' },
'Anhang wird hinzugefügt...',
);
const result = await api.addAttachment(
input.protocolId,
input.fileName,
@@ -198,7 +232,10 @@ export const deleteProtocolAttachment = authActionClient
const logger = await getLogger();
const api = createMeetingsApi(client);
logger.info({ name: 'meetings.attachment.delete' }, 'Anhang wird gelöscht...');
logger.info(
{ name: 'meetings.attachment.delete' },
'Anhang wird gelöscht...',
);
await api.deleteAttachment(input.attachmentId);
logger.info({ name: 'meetings.attachment.delete' }, 'Anhang gelöscht');
revalidatePath(REVALIDATION_PATH, 'page');

Some files were not shown because too many files have changed in this diff Show More