Add account hierarchy framework with migrations, RLS policies, and UI components
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. "{rawData[0][Number(mapping[field.key])]}"</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
z.B. "{rawData[0][Number(mapping[field.key])]}"
|
||||
</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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user