feat: add file upload and management features; enhance pagination and permissions handling
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m43s
Workflow / ⚫️ Test (push) Has been skipped

This commit is contained in:
T. Zehetbauer
2026-04-01 20:13:15 +02:00
parent db4e19c3af
commit bbb33aa63d
39 changed files with 2858 additions and 99 deletions

View File

@@ -16,6 +16,7 @@ import {
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Textarea } from '@kit/ui/textarea';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import {
@@ -151,10 +152,7 @@ export function CreateCourseForm({
<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"
/>
<Textarea {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -327,10 +325,7 @@ export function CreateCourseForm({
<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"
/>
<Textarea {...field} />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -0,0 +1,442 @@
'use client';
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 { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Checkbox } from '@kit/ui/checkbox';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { CreateCompetitionSchema } from '../schema/fischerei.schema';
import {
createCompetition,
updateCompetition,
} from '../server/actions/fischerei-actions';
interface CreateCompetitionFormProps {
accountId: string;
account: string;
waters: Array<{ id: string; name: string }>;
competition?: Record<string, unknown>;
}
export function CreateCompetitionForm({
accountId,
account,
waters,
competition,
}: CreateCompetitionFormProps) {
const router = useRouter();
const isEdit = !!competition;
const form = useForm({
resolver: zodResolver(CreateCompetitionSchema),
defaultValues: {
accountId,
name: (competition?.name as string) ?? '',
competitionDate: (competition?.competition_date as string) ?? todayISO(),
waterId: (competition?.water_id as string) ?? '',
maxParticipants:
competition?.max_participants != null
? Number(competition.max_participants)
: (undefined as number | undefined),
scoreByCount:
competition?.score_by_count != null
? Boolean(competition.score_by_count)
: false,
scoreByHeaviest:
competition?.score_by_heaviest != null
? Boolean(competition.score_by_heaviest)
: false,
scoreByTotalWeight:
competition?.score_by_total_weight != null
? Boolean(competition.score_by_total_weight)
: true,
scoreByLongest:
competition?.score_by_longest != null
? Boolean(competition.score_by_longest)
: false,
scoreByTotalLength:
competition?.score_by_total_length != null
? Boolean(competition.score_by_total_length)
: false,
separateMemberGuestScoring:
competition?.separate_member_guest_scoring != null
? Boolean(competition.separate_member_guest_scoring)
: false,
resultCountWeight:
competition?.result_count_weight != null
? Number(competition.result_count_weight)
: 3,
resultCountLength:
competition?.result_count_length != null
? Number(competition.result_count_length)
: 3,
resultCountCount:
competition?.result_count_count != null
? Number(competition.result_count_count)
: 3,
},
});
const { execute: executeCreate, isPending: isCreating } = useAction(
createCompetition,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Wettbewerb erstellt');
router.push(`/home/${account}/fischerei/competitions`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
},
},
);
const { execute: executeUpdate, isPending: isUpdating } = useAction(
updateCompetition,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Wettbewerb aktualisiert');
router.push(`/home/${account}/fischerei/competitions`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
},
},
);
const isPending = isCreating || isUpdating;
const handleSubmit = (data: Record<string, unknown>) => {
if (isEdit && competition?.id) {
executeUpdate({
...data,
competitionId: String(competition.id),
} as any);
} else {
executeCreate(data as any);
}
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => handleSubmit(data))}
className="space-y-6"
>
{/* Card 1: Grunddaten */}
<Card>
<CardHeader>
<CardTitle>Grunddaten</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name *</FormLabel>
<FormControl>
<Input {...field} data-test="competition-name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="competitionDate"
render={({ field }) => (
<FormItem>
<FormLabel>Datum *</FormLabel>
<FormControl>
<Input
type="date"
{...field}
data-test="competition-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="waterId"
render={({ field }) => (
<FormItem>
<FormLabel>Gewässer</FormLabel>
<FormControl>
<select
{...field}
data-test="competition-water-select"
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) => (
<option key={w.id} value={w.id}>
{w.name}
</option>
))}
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxParticipants"
render={({ field }) => (
<FormItem>
<FormLabel>Max. Teilnehmer</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 2: Wertungskriterien */}
<Card>
<CardHeader>
<CardTitle>Wertungskriterien</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="scoreByCount"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
data-test="competition-score-count"
/>
</FormControl>
<FormLabel className="font-normal">
Wertung nach Anzahl
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scoreByHeaviest"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
data-test="competition-score-heaviest"
/>
</FormControl>
<FormLabel className="font-normal">
Wertung nach schwerstem Fisch
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scoreByTotalWeight"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
data-test="competition-score-total-weight"
/>
</FormControl>
<FormLabel className="font-normal">
Wertung nach Gesamtgewicht
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scoreByLongest"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
data-test="competition-score-longest"
/>
</FormControl>
<FormLabel className="font-normal">
Wertung nach längstem Fisch
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scoreByTotalLength"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
data-test="competition-score-total-length"
/>
</FormControl>
<FormLabel className="font-normal">
Wertung nach Gesamtlänge
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="separateMemberGuestScoring"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
data-test="competition-separate-scoring"
/>
</FormControl>
<FormLabel className="font-normal">
Getrennte Wertung Mitglieder/Gäste
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Card 3: Ergebnis-Anzahl */}
<Card>
<CardHeader>
<CardTitle>Ergebnis-Anzahl</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField
control={form.control}
name="resultCountWeight"
render={({ field }) => (
<FormItem>
<FormLabel>Plätze Gewicht</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="resultCountLength"
render={({ field }) => (
<FormItem>
<FormLabel>Plätze Länge</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="resultCountCount"
render={({ field }) => (
<FormItem>
<FormLabel>Plätze Anzahl</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Submit */}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
data-test="competition-cancel-btn"
>
Abbrechen
</Button>
<Button
type="submit"
disabled={isPending}
data-test="competition-submit-btn"
>
{isPending
? 'Wird gespeichert...'
: isEdit
? 'Wettbewerb aktualisieren'
: 'Wettbewerb erstellen'}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,467 @@
'use client';
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 { 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 { CreateLeaseSchema } from '../schema/fischerei.schema';
import { createLease, updateLease } from '../server/actions/fischerei-actions';
interface CreateLeaseFormProps {
accountId: string;
account: string;
waters: Array<{ id: string; name: string }>;
lease?: Record<string, unknown>;
}
export function CreateLeaseForm({
accountId,
account,
waters,
lease,
}: CreateLeaseFormProps) {
const router = useRouter();
const isEdit = !!lease;
const form = useForm({
resolver: zodResolver(CreateLeaseSchema),
defaultValues: {
accountId,
waterId: (lease?.water_id as string) ?? '',
lessorName: (lease?.lessor_name as string) ?? '',
lessorAddress: (lease?.lessor_address as string) ?? '',
lessorPhone: (lease?.lessor_phone as string) ?? '',
lessorEmail: (lease?.lessor_email as string) ?? '',
startDate: (lease?.start_date as string) ?? todayISO(),
endDate: (lease?.end_date as string) ?? '',
durationYears:
lease?.duration_years != null
? Number(lease.duration_years)
: (undefined as number | undefined),
initialAmount:
lease?.initial_amount != null ? Number(lease.initial_amount) : 0,
fixedAnnualIncrease:
lease?.fixed_annual_increase != null
? Number(lease.fixed_annual_increase)
: 0,
percentageAnnualIncrease:
lease?.percentage_annual_increase != null
? Number(lease.percentage_annual_increase)
: 0,
paymentMethod: ((lease?.payment_method as string) ?? 'ueberweisung') as
| 'bar'
| 'lastschrift'
| 'ueberweisung',
accountHolder: (lease?.account_holder as string) ?? '',
iban: (lease?.iban as string) ?? '',
bic: (lease?.bic as string) ?? '',
locationDetails: (lease?.location_details as string) ?? '',
specialAgreements: (lease?.special_agreements as string) ?? '',
isArchived:
lease?.is_archived != null ? Boolean(lease.is_archived) : false,
},
});
const { execute: executeCreate, isPending: isCreating } = useAction(
createLease,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Pacht erstellt');
router.push(`/home/${account}/fischerei/leases`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
},
},
);
const { execute: executeUpdate, isPending: isUpdating } = useAction(
updateLease,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Pacht aktualisiert');
router.push(`/home/${account}/fischerei/leases`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
},
},
);
const isPending = isCreating || isUpdating;
const handleSubmit = (data: Record<string, unknown>) => {
if (isEdit && lease?.id) {
executeUpdate({ ...data, leaseId: String(lease.id) } as any);
} else {
executeCreate(data as any);
}
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => handleSubmit(data))}
className="space-y-6"
>
{/* Card 1: Gewässer & Verpächter */}
<Card>
<CardHeader>
<CardTitle>Gewässer & Verpächter</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="waterId"
render={({ field }) => (
<FormItem>
<FormLabel>Gewässer *</FormLabel>
<FormControl>
<select
{...field}
data-test="lease-water-select"
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) => (
<option key={w.id} value={w.id}>
{w.name}
</option>
))}
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lessorName"
render={({ field }) => (
<FormItem>
<FormLabel>Verpächter *</FormLabel>
<FormControl>
<Input {...field} data-test="lease-lessor-name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lessorAddress"
render={({ field }) => (
<FormItem>
<FormLabel>Adresse</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lessorPhone"
render={({ field }) => (
<FormItem>
<FormLabel>Telefon</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lessorEmail"
render={({ field }) => (
<FormItem>
<FormLabel>E-Mail</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Card 2: Laufzeit */}
<Card>
<CardHeader>
<CardTitle>Laufzeit</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField
control={form.control}
name="startDate"
render={({ field }) => (
<FormItem>
<FormLabel>Beginn *</FormLabel>
<FormControl>
<Input
type="date"
{...field}
data-test="lease-start-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endDate"
render={({ field }) => (
<FormItem>
<FormLabel>Ende</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="durationYears"
render={({ field }) => (
<FormItem>
<FormLabel>Laufzeit (Jahre)</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 3: Kosten & Zahlung */}
<Card>
<CardHeader>
<CardTitle>Kosten & Zahlung</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField
control={form.control}
name="initialAmount"
render={({ field }) => (
<FormItem>
<FormLabel>Jahresbetrag (EUR) *</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
min={0}
{...field}
data-test="lease-initial-amount"
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fixedAnnualIncrease"
render={({ field }) => (
<FormItem>
<FormLabel>Feste Erhöhung (EUR/Jahr)</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="percentageAnnualIncrease"
render={({ field }) => (
<FormItem>
<FormLabel>Erhöhung (%/Jahr)</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="paymentMethod"
render={({ field }) => (
<FormItem>
<FormLabel>Zahlungsart</FormLabel>
<FormControl>
<select
{...field}
data-test="lease-payment-method"
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value="ueberweisung">Überweisung</option>
<option value="lastschrift">Lastschrift</option>
<option value="bar">Bar</option>
</select>
</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} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bic"
render={({ field }) => (
<FormItem>
<FormLabel>BIC</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Card 4: Weitere Angaben */}
<Card>
<CardHeader>
<CardTitle>Weitere Angaben</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4">
<FormField
control={form.control}
name="locationDetails"
render={({ field }) => (
<FormItem>
<FormLabel>Lage / Standortdetails</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>
)}
/>
<FormField
control={form.control}
name="specialAgreements"
render={({ field }) => (
<FormItem>
<FormLabel>Sondervereinbarungen</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>
{/* Submit */}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
data-test="lease-cancel-btn"
>
Abbrechen
</Button>
<Button
type="submit"
disabled={isPending}
data-test="lease-submit-btn"
>
{isPending
? 'Wird gespeichert...'
: isEdit
? 'Pacht aktualisieren'
: 'Pacht erstellen'}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,274 @@
'use client';
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 { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Checkbox } from '@kit/ui/checkbox';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { CreatePermitSchema } from '../schema/fischerei.schema';
import {
createPermit,
updatePermit,
} from '../server/actions/fischerei-actions';
interface CreatePermitFormProps {
accountId: string;
account: string;
waters: Array<{ id: string; name: string }>;
permit?: Record<string, unknown>;
}
export function CreatePermitForm({
accountId,
account,
waters,
permit,
}: CreatePermitFormProps) {
const router = useRouter();
const isEdit = !!permit;
const form = useForm({
resolver: zodResolver(CreatePermitSchema),
defaultValues: {
accountId,
name: (permit?.name as string) ?? '',
shortCode: (permit?.short_code as string) ?? '',
primaryWaterId: (permit?.primary_water_id as string) ?? '',
totalQuantity:
permit?.total_quantity != null
? Number(permit.total_quantity)
: (undefined as number | undefined),
costCenterId: (permit?.cost_center_id as string | undefined) ?? undefined,
hejfishId: (permit?.hejfish_id as string) ?? '',
isForSale:
permit?.is_for_sale != null ? Boolean(permit.is_for_sale) : true,
isArchived:
permit?.is_archived != null ? Boolean(permit.is_archived) : false,
},
});
const { execute: executeCreate, isPending: isCreating } = useAction(
createPermit,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Erlaubnisschein erstellt');
router.push(`/home/${account}/fischerei/permits`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
},
},
);
const { execute: executeUpdate, isPending: isUpdating } = useAction(
updatePermit,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Erlaubnisschein aktualisiert');
router.push(`/home/${account}/fischerei/permits`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
},
},
);
const isPending = isCreating || isUpdating;
const handleSubmit = (data: Record<string, unknown>) => {
if (isEdit && permit?.id) {
executeUpdate({ ...data, permitId: String(permit.id) } as any);
} else {
executeCreate(data as any);
}
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => handleSubmit(data))}
className="space-y-6"
>
{/* Card 1: Grunddaten */}
<Card>
<CardHeader>
<CardTitle>Grunddaten</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Bezeichnung *</FormLabel>
<FormControl>
<Input {...field} data-test="permit-name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="shortCode"
render={({ field }) => (
<FormItem>
<FormLabel>Kurzcode</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="primaryWaterId"
render={({ field }) => (
<FormItem>
<FormLabel>Hauptgewässer</FormLabel>
<FormControl>
<select
{...field}
data-test="permit-water-select"
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) => (
<option key={w.id} value={w.id}>
{w.name}
</option>
))}
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="totalQuantity"
render={({ field }) => (
<FormItem>
<FormLabel>Gesamtmenge</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="hejfishId"
render={({ field }) => (
<FormItem>
<FormLabel>Hejfish-ID</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Card 2: Optionen */}
<Card>
<CardHeader>
<CardTitle>Optionen</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="isForSale"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
data-test="permit-for-sale"
/>
</FormControl>
<FormLabel className="font-normal">Zum Verkauf</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isArchived"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
data-test="permit-archived"
/>
</FormControl>
<FormLabel className="font-normal">Archiviert</FormLabel>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Submit */}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
data-test="permit-cancel-btn"
>
Abbrechen
</Button>
<Button
type="submit"
disabled={isPending}
data-test="permit-submit-btn"
>
{isPending
? 'Wird gespeichert...'
: isEdit
? 'Erlaubnisschein aktualisieren'
: 'Erlaubnisschein erstellen'}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -9,4 +9,7 @@ export { CreateStockingForm } from './create-stocking-form';
export { CatchBooksDataTable } from './catch-books-data-table';
export { CompetitionsDataTable } from './competitions-data-table';
export { LeasesDataTable } from './leases-data-table';
export { CreateLeaseForm } from './create-lease-form';
export { PermitsDataTable } from './permits-data-table';
export { CreatePermitForm } from './create-permit-form';
export { CreateCompetitionForm } from './create-competition-form';

View File

@@ -28,6 +28,7 @@ import {
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Textarea } from '@kit/ui/textarea';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import { CreateMemberSchema } from '../schema/member.schema';
@@ -585,10 +586,7 @@ export function CreateMemberForm({
<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"
/>
<Textarea {...field} />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -18,6 +18,7 @@ import {
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { Textarea } from '@kit/ui/textarea';
import { UpdateMemberSchema } from '../schema/member.schema';
import { updateMember } from '../server/actions/member-actions';
@@ -479,11 +480,7 @@ export function EditMemberForm({ member, account, accountId }: Props) {
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"
/>
<Textarea {...field} rows={4} />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -31,6 +31,7 @@
"@supabase/supabase-js": "catalog:",
"@types/react": "catalog:",
"next": "catalog:",
"next-intl": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
"react-hook-form": "catalog:",

View File

@@ -2,6 +2,8 @@
import { useState } from 'react';
import { Button } from '@kit/ui/button';
import type { CmsFieldType } from '../schema/module.schema';
import { FieldRenderer } from './field-renderer';
@@ -114,13 +116,13 @@ export function ModuleForm({
))}
<div className="flex justify-end gap-2 border-t pt-4">
<button
<Button
type="submit"
disabled={isLoading}
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"
data-test="module-record-submit-btn"
>
{isLoading ? 'Wird gespeichert...' : 'Speichern'}
</button>
</Button>
</div>
</form>
);

View File

@@ -1,5 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { formatDate } from '@kit/shared/dates';
import type { CmsFieldType } from '../schema/module.schema';
@@ -56,6 +58,7 @@ export function ModuleTable({
onSelectionChange,
currentSort,
}: ModuleTableProps) {
const t = useTranslations('cms.modules');
const visibleFields = fields
.filter((f) => f.show_in_table)
.sort((a, b) => a.sort_order - b.sort_order);
@@ -137,7 +140,7 @@ export function ModuleTable({
colSpan={visibleFields.length + (onSelectionChange ? 1 : 0)}
className="text-muted-foreground p-8 text-center"
>
Keine Datensätze gefunden
{t('noRecords')}
</td>
</tr>
) : (
@@ -183,8 +186,11 @@ export function ModuleTable({
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between px-2">
<span className="text-muted-foreground text-sm">
{pagination.total} Datensätze Seite {pagination.page} von{' '}
{pagination.totalPages}
{t('paginationSummary', {
total: pagination.total,
page: pagination.page,
totalPages: pagination.totalPages,
})}
</span>
<div className="flex gap-1">
<button
@@ -192,14 +198,14 @@ export function ModuleTable({
disabled={pagination.page <= 1}
className="hover:bg-muted rounded border px-3 py-1 text-sm disabled:opacity-50"
>
Zurück
{t('paginationPrevious')}
</button>
<button
onClick={() => onPageChange(pagination.page + 1)}
disabled={pagination.page >= pagination.totalPages}
className="hover:bg-muted rounded border px-3 py-1 text-sm disabled:opacity-50"
>
Weiter
{t('paginationNext')}
</button>
</div>
</div>

View File

@@ -0,0 +1,69 @@
'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 { createModuleBuilderApi } from '../api';
export const uploadFile = authActionClient
.inputSchema(
z.object({
accountId: z.string().uuid(),
fileName: z.string().min(1),
fileType: z.string().min(1),
fileSize: z
.number()
.int()
.min(1)
.max(10 * 1024 * 1024), // 10MB max
base64: z.string().min(1),
moduleName: z.string().optional(),
fieldName: z.string().optional(),
recordId: z.string().uuid().optional(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createModuleBuilderApi(client);
logger.info(
{ name: 'files.upload', fileName: input.fileName },
'Uploading file...',
);
const data = await api.files.uploadFile({
accountId: input.accountId,
userId: ctx.user.id,
file: {
name: input.fileName,
type: input.fileType,
size: input.fileSize,
base64: input.base64,
},
moduleName: input.moduleName,
fieldName: input.fieldName,
recordId: input.recordId,
});
logger.info({ name: 'files.upload' }, 'File uploaded');
return { success: true, data };
});
export const deleteFile = authActionClient
.inputSchema(z.object({ fileId: z.string().uuid() }))
.action(async ({ parsedInput: { fileId } }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createModuleBuilderApi(client);
logger.info({ name: 'files.delete', fileId }, 'Deleting file...');
await api.files.deleteFile(fileId);
logger.info({ name: 'files.delete' }, 'File deleted');
return { success: true };
});

View File

@@ -1,5 +1,7 @@
'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';
@@ -69,3 +71,125 @@ export const deleteModule = authActionClient
return { success: true };
});
// ── Permissions ──────────────────────────────────────────────────
export const upsertModulePermission = authActionClient
.inputSchema(
z.object({
moduleId: z.string().uuid(),
role: z.string().min(1),
canRead: z.boolean(),
canInsert: z.boolean(),
canUpdate: z.boolean(),
canDelete: z.boolean(),
canExport: z.boolean(),
canImport: z.boolean(),
canLock: z.boolean(),
canBulkEdit: z.boolean(),
canManage: z.boolean(),
canPrint: z.boolean(),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createModuleBuilderApi(client);
logger.info(
{
name: 'modules.permissions.upsert',
moduleId: input.moduleId,
role: input.role,
},
'Upserting module permission...',
);
const data = await api.modules.upsertPermission({
moduleId: input.moduleId,
role: input.role,
can_read: input.canRead,
can_insert: input.canInsert,
can_update: input.canUpdate,
can_delete: input.canDelete,
can_export: input.canExport,
can_import: input.canImport,
can_lock: input.canLock,
can_bulk_edit: input.canBulkEdit,
can_manage: input.canManage,
can_print: input.canPrint,
});
logger.info(
{
name: 'modules.permissions.upsert',
moduleId: input.moduleId,
role: input.role,
},
'Module permission upserted',
);
return { success: true, data };
});
// ── Relations ────────────────────────────────────────────────────
export const createModuleRelation = authActionClient
.inputSchema(
z.object({
sourceModuleId: z.string().uuid(),
sourceFieldId: z.string().uuid(),
targetModuleId: z.string().uuid(),
targetFieldId: z.string().uuid().optional(),
relationType: z.enum(['has_one', 'has_many', 'belongs_to']),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createModuleBuilderApi(client);
logger.info(
{
name: 'modules.relations.create',
sourceModuleId: input.sourceModuleId,
targetModuleId: input.targetModuleId,
},
'Creating module relation...',
);
const data = await api.modules.createRelation(input);
logger.info(
{ name: 'modules.relations.create', relationId: data.id },
'Module relation created',
);
return { success: true, data };
});
export const deleteModuleRelation = authActionClient
.inputSchema(
z.object({
relationId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: { relationId } }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createModuleBuilderApi(client);
logger.info(
{ name: 'modules.relations.delete', relationId },
'Deleting module relation...',
);
await api.modules.deleteRelation(relationId);
logger.info(
{ name: 'modules.relations.delete', relationId },
'Module relation deleted',
);
return { success: true };
});

View File

@@ -3,6 +3,7 @@ import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import { createAuditService } from './services/audit.service';
import { createFileService } from './services/file.service';
import { createModuleDefinitionService } from './services/module-definition.service';
import { createModuleQueryService } from './services/module-query.service';
import { createRecordCrudService } from './services/record-crud.service';
@@ -20,5 +21,6 @@ export function createModuleBuilderApi(client: SupabaseClient<Database>) {
query: createModuleQueryService(client),
records: createRecordCrudService(client),
audit: createAuditService(client),
files: createFileService(client),
};
}

View File

@@ -0,0 +1,115 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
interface UploadFileInput {
accountId: string;
userId: string;
file: {
name: string;
type: string;
size: number;
base64: string;
};
moduleName?: string;
fieldName?: string;
recordId?: string;
}
interface ListFilesOptions {
moduleName?: string;
recordId?: string;
search?: string;
page?: number;
pageSize?: number;
}
export function createFileService(client: SupabaseClient<Database>) {
return {
async uploadFile(input: UploadFileInput) {
const path = `${input.accountId}/${Date.now()}-${input.file.name}`;
const buffer = Buffer.from(input.file.base64, 'base64');
const { error: uploadError } = await client.storage
.from('cms-files')
.upload(path, buffer, {
contentType: input.file.type,
});
if (uploadError) throw uploadError;
const { data, error } = await client
.from('cms_files')
.insert({
account_id: input.accountId,
record_id: input.recordId ?? null,
module_name: input.moduleName ?? null,
field_name: input.fieldName ?? null,
file_name: input.file.name,
original_name: input.file.name,
mime_type: input.file.type,
file_size: input.file.size,
storage_path: path,
created_by: input.userId,
})
.select()
.single();
if (error) throw error;
return data;
},
async listFiles(accountId: string, opts?: ListFilesOptions) {
let q = client
.from('cms_files')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (opts?.moduleName) {
q = q.eq('module_name', opts.moduleName);
}
if (opts?.recordId) {
q = q.eq('record_id', opts.recordId);
}
if (opts?.search) {
q = q.ilike('file_name', `%${opts.search}%`);
}
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 25;
q = q.range((page - 1) * pageSize, page * pageSize - 1);
const { data, error, count } = await q;
if (error) throw error;
return { data: data ?? [], total: count ?? 0, page, pageSize };
},
async deleteFile(fileId: string) {
const { data: file, error: getErr } = await client
.from('cms_files')
.select('storage_path')
.eq('id', fileId)
.single();
if (getErr) throw getErr;
await client.storage.from('cms-files').remove([file.storage_path]);
const { error } = await client
.from('cms_files')
.delete()
.eq('id', fileId);
if (error) throw error;
},
getPublicUrl(storagePath: string) {
return client.storage.from('cms-files').getPublicUrl(storagePath).data
.publicUrl;
},
};
}

View File

@@ -144,5 +144,83 @@ export function createModuleDefinitionService(
if (error) throw error;
},
// ── Permissions ──────────────────────────────────────────────
async listPermissions(moduleId: string) {
const { data, error } = await client
.from('module_permissions')
.select('*')
.eq('module_id', moduleId);
if (error) throw error;
return data ?? [];
},
async upsertPermission(input: {
moduleId: string;
role: string;
[key: string]: unknown;
}) {
const { moduleId, role, ...perms } = input;
const { data, error } = await client
.from('module_permissions')
.upsert(
{ module_id: moduleId, role, ...perms },
{ onConflict: 'module_id,role' },
)
.select()
.single();
if (error) throw error;
return data;
},
// ── Relations ────────────────────────────────────────────────
async listRelations(moduleId: string) {
const { data, error } = await client
.from('module_relations')
.select(
'*, source_module:modules!module_relations_source_module_id_fkey(id, display_name), target_module:modules!module_relations_target_module_id_fkey(id, display_name), source_field:module_fields!module_relations_source_field_id_fkey(id, name, display_name)',
)
.or(`source_module_id.eq.${moduleId},target_module_id.eq.${moduleId}`);
if (error) throw error;
return data ?? [];
},
async createRelation(input: {
sourceModuleId: string;
sourceFieldId: string;
targetModuleId: string;
targetFieldId?: string;
relationType: string;
}) {
const { data, error } = await client
.from('module_relations')
.insert({
source_module_id: input.sourceModuleId,
source_field_id: input.sourceFieldId,
target_module_id: input.targetModuleId,
target_field_id: input.targetFieldId ?? null,
relation_type: input.relationType,
})
.select()
.single();
if (error) throw error;
return data;
},
async deleteRelation(relationId: string) {
const { error } = await client
.from('module_relations')
.delete()
.eq('id', relationId);
if (error) throw error;
},
};
}

View File

@@ -16,6 +16,7 @@ import {
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Textarea } from '@kit/ui/textarea';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import {
@@ -119,10 +120,10 @@ export function CreateNewsletterForm({
<FormItem>
<FormLabel>Inhalt (HTML) *</FormLabel>
<FormControl>
<textarea
<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"
className="min-h-[200px] font-mono"
placeholder="<h1>Hallo!</h1><p>Ihr Newsletter-Inhalt...</p>"
data-test="newsletter-body-input"
/>
@@ -138,10 +139,9 @@ export function CreateNewsletterForm({
<FormItem>
<FormLabel>Nur-Text-Version (optional)</FormLabel>
<FormControl>
<textarea
<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"
data-test="newsletter-text-input"
/>

View File

@@ -5,6 +5,7 @@ import { useCallback } from 'react';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import { Search } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '../shadcn/button';
import { Input } from '../shadcn/input';
@@ -40,11 +41,13 @@ interface ListToolbarProps {
* Resets to page 1 on any filter/search change.
*/
export function ListToolbar({
searchPlaceholder = 'Suchen...',
searchPlaceholder,
searchParam = 'q',
filters = [],
showSearch = true,
}: ListToolbarProps) {
const t = useTranslations('common');
const resolvedPlaceholder = searchPlaceholder ?? t('searchPlaceholder');
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -84,12 +87,12 @@ export function ListToolbar({
name="search"
type="search"
defaultValue={currentSearch}
placeholder={searchPlaceholder}
placeholder={resolvedPlaceholder}
className="w-64"
/>
<Button type="submit" variant="outline" size="sm">
<Search className="mr-1 h-4 w-4" />
Suchen
{t('search')}
</Button>
</form>
)}