feat: add delete functionality for leases, catch books, and permits; implement newsletter update feature
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m52s
Workflow / ⚫️ Test (push) Has been skipped

This commit is contained in:
T. Zehetbauer
2026-04-01 17:53:39 +02:00
parent c6b2824da8
commit 080ec1cb47
22 changed files with 798 additions and 210 deletions

View File

@@ -0,0 +1,94 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Plus } from 'lucide-react';
import { createSession } from '@kit/course-management/actions/course-actions';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
interface Props {
courseId: string;
}
export function CreateSessionDialog({ courseId }: Props) {
const router = useRouter();
const [open, setOpen] = useState(false);
const { execute, isPending } = useActionWithToast(createSession, {
successMessage: 'Termin erstellt',
errorMessage: 'Fehler beim Erstellen',
onSuccess: () => {
setOpen(false);
router.refresh();
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Neuer Termin
</Button>
</DialogTrigger>
<DialogContent>
<form
onSubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
execute({
courseId,
sessionDate: fd.get('sessionDate') as string,
startTime: fd.get('startTime') as string,
endTime: fd.get('endTime') as string,
notes: (fd.get('notes') as string) || undefined,
});
}}
>
<DialogHeader>
<DialogTitle>Neuen Termin erstellen</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="sessionDate">Datum *</Label>
<Input id="sessionDate" name="sessionDate" type="date" required />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startTime">Beginn *</Label>
<Input id="startTime" name="startTime" type="time" required />
</div>
<div className="space-y-2">
<Label htmlFor="endTime">Ende *</Label>
<Input id="endTime" name="endTime" type="time" required />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Notizen</Label>
<Input id="notes" name="notes" />
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird erstellt...' : 'Termin erstellen'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -20,6 +20,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { CreateSessionDialog } from './create-session-dialog';
import { DeleteCourseButton } from './delete-course-button';
interface PageProps {
@@ -214,11 +215,14 @@ export default async function CourseDetailPage({ params }: PageProps) {
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Termine</CardTitle>
<Link href={`/home/${account}/courses/${courseId}/attendance`}>
<Button variant="outline" size="sm">
Anwesenheit
</Button>
</Link>
<div className="flex gap-2">
<CreateSessionDialog courseId={courseId} />
<Link href={`/home/${account}/courses/${courseId}/attendance`}>
<Button variant="outline" size="sm">
Anwesenheit
</Button>
</Link>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border">

View File

@@ -72,6 +72,7 @@ export default async function CatchBooksPage({ params, searchParams }: Props) {
page={page}
pageSize={25}
account={account}
accountId={acct.id}
/>
</CmsPageShell>
);

View File

@@ -62,6 +62,7 @@ export default async function CompetitionsPage({
page={page}
pageSize={25}
account={account}
accountId={acct.id}
/>
</CmsPageShell>
);

View File

@@ -1,10 +1,9 @@
import { createFischereiApi } from '@kit/fischerei/api';
import { FischereiTabNavigation } from '@kit/fischerei/components';
import { LEASE_PAYMENT_LABELS } from '@kit/fischerei/lib/fischerei-constants';
import { formatDate } from '@kit/shared/dates';
import {
FischereiTabNavigation,
LeasesDataTable,
} from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { ListToolbar } from '@kit/ui/list-toolbar';
import { AccountNotFound } from '~/components/account-not-found';
@@ -78,84 +77,11 @@ export default async function LeasesPage({ params, searchParams }: Props) {
},
]}
/>
<Card>
<CardHeader>
<CardTitle>Pachten ({result.total})</CardTitle>
</CardHeader>
<CardContent>
{result.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 Pachten vorhanden
</h3>
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
Erstellen Sie Ihren ersten Pachtvertrag.
</p>
</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Verpächter</th>
<th className="p-3 text-left font-medium">Gewässer</th>
<th className="p-3 text-left font-medium">Beginn</th>
<th className="p-3 text-left font-medium">Ende</th>
<th className="p-3 text-right font-medium">
Jahresbetrag ()
</th>
<th className="p-3 text-left font-medium">Zahlungsart</th>
</tr>
</thead>
<tbody>
{result.data.map((lease: Record<string, unknown>) => {
const waters = lease.waters as Record<
string,
unknown
> | null;
const paymentMethod = String(
lease.payment_method ?? 'ueberweisung',
);
return (
<tr
key={String(lease.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">
{String(lease.lessor_name)}
</td>
<td className="p-3">
{waters ? String(waters.name) : '—'}
</td>
<td className="p-3">
{formatDate(lease.start_date)}
</td>
<td className="p-3">
{lease.end_date
? formatDate(lease.end_date)
: 'unbefristet'}
</td>
<td className="p-3 text-right">
{lease.initial_amount != null
? `${Number(lease.initial_amount).toLocaleString('de-DE', { minimumFractionDigits: 2 })} €`
: '—'}
</td>
<td className="p-3">
<Badge variant="outline">
{LEASE_PAYMENT_LABELS[paymentMethod] ??
paymentMethod}
</Badge>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<LeasesDataTable
data={result.data}
total={result.total}
accountId={acct.id}
/>
</div>
</CmsPageShell>
);

View File

@@ -1,7 +1,9 @@
import { createFischereiApi } from '@kit/fischerei/api';
import { FischereiTabNavigation } from '@kit/fischerei/components';
import {
FischereiTabNavigation,
PermitsDataTable,
} from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
@@ -35,76 +37,10 @@ export default async function PermitsPage({ params }: Props) {
Erlaubnisscheine und Gewässerkarten verwalten
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Erlaubnisscheine ({permits.length})</CardTitle>
</CardHeader>
<CardContent>
{permits.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 Erlaubnisscheine vorhanden
</h3>
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
Erstellen Sie Ihren ersten Erlaubnisschein.
</p>
</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Bezeichnung</th>
<th className="p-3 text-left font-medium">Kurzcode</th>
<th className="p-3 text-left font-medium">
Hauptgewässer
</th>
<th className="p-3 text-right font-medium">
Gesamtmenge
</th>
<th className="p-3 text-center font-medium">
Zum Verkauf
</th>
</tr>
</thead>
<tbody>
{permits.map((permit: Record<string, unknown>) => {
const waters = permit.waters as Record<
string,
unknown
> | null;
return (
<tr
key={String(permit.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">
{String(permit.name)}
</td>
<td className="text-muted-foreground p-3">
{String(permit.short_code ?? '—')}
</td>
<td className="p-3">
{waters ? String(waters.name) : '—'}
</td>
<td className="p-3 text-right">
{permit.total_quantity != null
? String(permit.total_quantity)
: '—'}
</td>
<td className="p-3 text-center">
{permit.is_for_sale ? '✓' : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<PermitsDataTable
data={permits as Array<Record<string, unknown>>}
accountId={acct.id}
/>
</div>
</CmsPageShell>
);

View File

@@ -0,0 +1,45 @@
import { createNewsletterApi } from '@kit/newsletter/api';
import { CreateNewsletterForm } from '@kit/newsletter/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string; campaignId: string }>;
}
export default async function EditNewsletterPage({ params }: Props) {
const { account, campaignId } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createNewsletterApi(client);
const newsletter = await api.getNewsletter(campaignId);
if (!newsletter) return <AccountNotFound />;
const n = newsletter as Record<string, unknown>;
return (
<CmsPageShell account={account} title="Newsletter bearbeiten">
<CreateNewsletterForm
accountId={acct.id}
account={account}
newsletterId={campaignId}
initialData={{
subject: String(n.subject ?? ''),
bodyHtml: String(n.body_html ?? ''),
bodyText: String(n.body_text ?? ''),
scheduledAt: String(n.scheduled_at ?? ''),
}}
/>
</CmsPageShell>
);
}

View File

@@ -1,10 +1,11 @@
import Link from 'next/link';
import { ArrowLeft, Send, Users } from 'lucide-react';
import { ArrowLeft, Pencil, Send, Users } from 'lucide-react';
import { createNewsletterApi } from '@kit/newsletter/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
@@ -74,9 +75,19 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
<CardTitle>
{String(newsletter.subject ?? '(Kein Betreff)')}
</CardTitle>
<Badge variant={NEWSLETTER_STATUS_VARIANT[status] ?? 'secondary'}>
{NEWSLETTER_STATUS_LABEL[status] ?? status}
</Badge>
<div className="flex items-center gap-2">
{status === 'draft' && (
<Button asChild variant="outline" size="sm">
<Link href={`/home/${account}/newsletter/${campaignId}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Bearbeiten
</Link>
</Button>
)}
<Badge variant={NEWSLETTER_STATUS_VARIANT[status] ?? 'secondary'}>
{NEWSLETTER_STATUS_LABEL[status] ?? status}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">

View File

@@ -0,0 +1,49 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
import {
VerbandTabNavigation,
CreateClubForm,
} from '@kit/verbandsverwaltung/components';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string; clubId: string }>;
}
export default async function EditClubPage({ params }: Props) {
const { account, clubId } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createVerbandApi(client);
const [club, types] = await Promise.all([
api.getClub(clubId),
api.listTypes(acct.id),
]);
if (!club) return <AccountNotFound />;
return (
<CmsPageShell
account={account}
title={`${String((club as Record<string, unknown>).name)} — Bearbeiten`}
>
<VerbandTabNavigation account={account} activeTab="clubs" />
<CreateClubForm
accountId={acct.id}
account={account}
types={types.map((t) => ({ id: t.id, name: t.name }))}
club={club as Record<string, unknown>}
/>
</CmsPageShell>
);
}

View File

@@ -1,4 +1,9 @@
import Link from 'next/link';
import { Pencil } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
import {
VerbandTabNavigation,
@@ -64,6 +69,12 @@ export default async function ClubDetailPage({ params }: Props) {
)}
</div>
</div>
<Button asChild variant="outline" size="sm">
<Link href={`/home/${account}/verband/clubs/${clubId}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Bearbeiten
</Link>
</Button>
</div>
{/* Contacts */}

View File

@@ -4,16 +4,19 @@ import { useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Check } from 'lucide-react';
import { Pencil } from 'lucide-react';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import {
CATCH_BOOK_STATUS_LABELS,
CATCH_BOOK_STATUS_COLORS,
} from '../lib/fischerei-constants';
import { deleteCatchBook } from '../server/actions/fischerei-actions';
import { DeleteConfirmButton } from './delete-confirm-button';
interface CatchBooksDataTableProps {
data: Array<Record<string, unknown>>;
@@ -21,6 +24,7 @@ interface CatchBooksDataTableProps {
page: number;
pageSize: number;
account: string;
accountId: string;
}
const STATUS_OPTIONS = [
@@ -38,10 +42,19 @@ export function CatchBooksDataTable({
page,
pageSize,
account,
accountId,
}: CatchBooksDataTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { execute: executeDelete, isPending: isDeleting } = useActionWithToast(
deleteCatchBook,
{
successMessage: 'Fangbuch gelöscht',
onSuccess: () => router.refresh(),
},
);
const currentYear = searchParams.get('year') ?? '';
const currentStatus = searchParams.get('status') ?? '';
const totalPages = Math.max(1, Math.ceil(total / pageSize));
@@ -146,6 +159,7 @@ export function CatchBooksDataTable({
<th className="p-3 text-right font-medium">Angeltage</th>
<th className="p-3 text-right font-medium">Fänge</th>
<th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-right font-medium">Aktionen</th>
</tr>
</thead>
<tbody>
@@ -190,6 +204,34 @@ export function CatchBooksDataTable({
{CATCH_BOOK_STATUS_LABELS[status] ?? status}
</Badge>
</td>
<td className="p-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
data-test="catchbook-edit-btn"
onClick={(e) => {
e.stopPropagation();
router.push(
`/home/${account}/fischerei/catch-books/${String(cb.id)}`,
);
}}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteConfirmButton
title="Fangbuch löschen"
description="Möchten Sie dieses Fangbuch wirklich löschen? Alle zugehörigen Fangeinträge werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden."
isPending={isDeleting}
onConfirm={() =>
executeDelete({
catchBookId: String(cb.id),
accountId,
})
}
/>
</div>
</td>
</tr>
);
})}

View File

@@ -5,11 +5,15 @@ import { useCallback } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Plus } from 'lucide-react';
import { Pencil, 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';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import { deleteCompetition } from '../server/actions/fischerei-actions';
import { DeleteConfirmButton } from './delete-confirm-button';
interface CompetitionsDataTableProps {
data: Array<Record<string, unknown>>;
@@ -17,6 +21,7 @@ interface CompetitionsDataTableProps {
page: number;
pageSize: number;
account: string;
accountId: string;
}
export function CompetitionsDataTable({
@@ -25,11 +30,20 @@ export function CompetitionsDataTable({
page,
pageSize,
account,
accountId,
}: CompetitionsDataTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const { execute: executeDelete, isPending: isDeleting } = useActionWithToast(
deleteCompetition,
{
successMessage: 'Wettbewerb gelöscht',
onSuccess: () => router.refresh(),
},
);
const updateParams = useCallback(
(updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
@@ -95,6 +109,7 @@ export function CompetitionsDataTable({
<th className="p-3 text-right font-medium">
Max. Teilnehmer
</th>
<th className="p-3 text-right font-medium">Aktionen</th>
</tr>
</thead>
<tbody>
@@ -126,6 +141,34 @@ export function CompetitionsDataTable({
? String(comp.max_participants)
: '—'}
</td>
<td className="p-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
data-test="competition-edit-btn"
onClick={(e) => {
e.stopPropagation();
router.push(
`/home/${account}/fischerei/competitions/${String(comp.id)}`,
);
}}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteConfirmButton
title="Wettbewerb löschen"
description="Möchten Sie diesen Wettbewerb wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden."
isPending={isDeleting}
onConfirm={() =>
executeDelete({
competitionId: String(comp.id),
accountId,
})
}
/>
</div>
</td>
</tr>
);
})}

View File

@@ -8,3 +8,5 @@ export { StockingDataTable } from './stocking-data-table';
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 { PermitsDataTable } from './permits-data-table';

View File

@@ -0,0 +1,125 @@
'use client';
import { useRouter } from 'next/navigation';
import { formatDate } from '@kit/shared/dates';
import { Badge } from '@kit/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import { LEASE_PAYMENT_LABELS } from '../lib/fischerei-constants';
import { deleteLease } from '../server/actions/fischerei-actions';
import { DeleteConfirmButton } from './delete-confirm-button';
interface LeasesDataTableProps {
data: Array<Record<string, unknown>>;
total: number;
accountId: string;
}
export function LeasesDataTable({
data,
total,
accountId,
}: LeasesDataTableProps) {
const router = useRouter();
const { execute: executeDelete, isPending: isDeleting } = useActionWithToast(
deleteLease,
{
successMessage: 'Pacht gelöscht',
onSuccess: () => router.refresh(),
},
);
return (
<Card>
<CardHeader>
<CardTitle>Pachten ({total})</CardTitle>
</CardHeader>
<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 Pachten vorhanden</h3>
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
Erstellen Sie Ihren ersten Pachtvertrag.
</p>
</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Verpächter</th>
<th className="p-3 text-left font-medium">Gewässer</th>
<th className="p-3 text-left font-medium">Beginn</th>
<th className="p-3 text-left font-medium">Ende</th>
<th className="p-3 text-right font-medium">
Jahresbetrag (EUR)
</th>
<th className="p-3 text-left font-medium">Zahlungsart</th>
<th className="p-3 text-right font-medium">Aktionen</th>
</tr>
</thead>
<tbody>
{data.map((lease) => {
const waters = lease.waters as Record<string, unknown> | null;
const paymentMethod = String(
lease.payment_method ?? 'ueberweisung',
);
return (
<tr
key={String(lease.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">
{String(lease.lessor_name)}
</td>
<td className="p-3">
{waters ? String(waters.name) : '\u2014'}
</td>
<td className="p-3">
{formatDate(lease.start_date as string | null)}
</td>
<td className="p-3">
{lease.end_date
? formatDate(lease.end_date as string | null)
: 'unbefristet'}
</td>
<td className="p-3 text-right">
{lease.initial_amount != null
? `${Number(lease.initial_amount).toLocaleString('de-DE', { minimumFractionDigits: 2 })} \u20AC`
: '\u2014'}
</td>
<td className="p-3">
<Badge variant="outline">
{LEASE_PAYMENT_LABELS[paymentMethod] ?? paymentMethod}
</Badge>
</td>
<td className="p-3 text-right">
<div className="flex items-center justify-end gap-1">
<DeleteConfirmButton
title="Pacht löschen"
description="Möchten Sie diesen Pachtvertrag wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden."
isPending={isDeleting}
onConfirm={() =>
executeDelete({
leaseId: String(lease.id),
accountId,
})
}
/>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,107 @@
'use client';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import { deletePermit } from '../server/actions/fischerei-actions';
import { DeleteConfirmButton } from './delete-confirm-button';
interface PermitsDataTableProps {
data: Array<Record<string, unknown>>;
accountId: string;
}
export function PermitsDataTable({ data, accountId }: PermitsDataTableProps) {
const router = useRouter();
const { execute: executeDelete, isPending: isDeleting } = useActionWithToast(
deletePermit,
{
successMessage: 'Erlaubnisschein gelöscht',
onSuccess: () => router.refresh(),
},
);
return (
<Card>
<CardHeader>
<CardTitle>Erlaubnisscheine ({data.length})</CardTitle>
</CardHeader>
<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 Erlaubnisscheine vorhanden
</h3>
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
Erstellen Sie Ihren ersten Erlaubnisschein.
</p>
</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Bezeichnung</th>
<th className="p-3 text-left font-medium">Kurzcode</th>
<th className="p-3 text-left font-medium">Hauptgewässer</th>
<th className="p-3 text-right font-medium">Gesamtmenge</th>
<th className="p-3 text-center font-medium">Zum Verkauf</th>
<th className="p-3 text-right font-medium">Aktionen</th>
</tr>
</thead>
<tbody>
{data.map((permit) => {
const waters = permit.waters as Record<
string,
unknown
> | null;
return (
<tr
key={String(permit.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">{String(permit.name)}</td>
<td className="text-muted-foreground p-3">
{String(permit.short_code ?? '\u2014')}
</td>
<td className="p-3">
{waters ? String(waters.name) : '\u2014'}
</td>
<td className="p-3 text-right">
{permit.total_quantity != null
? String(permit.total_quantity)
: '\u2014'}
</td>
<td className="p-3 text-center">
{permit.is_for_sale ? '\u2713' : '\u2014'}
</td>
<td className="p-3 text-right">
<div className="flex items-center justify-end gap-1">
<DeleteConfirmButton
title="Erlaubnisschein löschen"
description="Möchten Sie diesen Erlaubnisschein wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden."
isPending={isDeleting}
onConfirm={() =>
executeDelete({
permitId: String(permit.id),
accountId,
})
}
/>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -291,6 +291,25 @@ export const updateLease = authActionClient
return { success: true, data: result };
});
export const deleteLease = authActionClient
.inputSchema(
z.object({
leaseId: z.string().uuid(),
accountId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info({ name: 'fischerei.lease.delete' }, 'Deleting lease...');
await api.deleteLease(input.leaseId);
logger.info({ name: 'fischerei.lease.delete' }, 'Lease deleted');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true };
});
// =====================================================
// Catch Books
// =====================================================
@@ -386,6 +405,28 @@ export const reviewCatchBook = authActionClient
return { success: true, data: result };
});
export const deleteCatchBook = authActionClient
.inputSchema(
z.object({
catchBookId: z.string().uuid(),
accountId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info(
{ name: 'fischerei.catchBook.delete' },
'Deleting catch book...',
);
await api.deleteCatchBook(input.catchBookId);
logger.info({ name: 'fischerei.catchBook.delete' }, 'Catch book deleted');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true };
});
// =====================================================
// Catches
// =====================================================
@@ -473,6 +514,25 @@ export const updatePermit = authActionClient
return { success: true, data: result };
});
export const deletePermit = authActionClient
.inputSchema(
z.object({
permitId: z.string().uuid(),
accountId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info({ name: 'fischerei.permit.delete' }, 'Deleting permit...');
await api.deletePermit(input.permitId);
logger.info({ name: 'fischerei.permit.delete' }, 'Permit deleted');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true };
});
// =====================================================
// Inspectors
// =====================================================

View File

@@ -670,6 +670,14 @@ export function createFischereiApi(client: SupabaseClient<Database>) {
return data;
},
async deleteLease(leaseId: string) {
const { error } = await client
.from('fishing_leases')
.delete()
.eq('id', leaseId);
if (error) throw error;
},
// =====================================================
// Catch Books
// =====================================================
@@ -836,6 +844,14 @@ export function createFischereiApi(client: SupabaseClient<Database>) {
return data;
},
async deleteCatchBook(catchBookId: string) {
const { error } = await client
.from('catch_books')
.delete()
.eq('id', catchBookId);
if (error) throw error;
},
// =====================================================
// Catches
// =====================================================
@@ -1010,6 +1026,24 @@ export function createFischereiApi(client: SupabaseClient<Database>) {
return data;
},
async getPermit(permitId: string) {
const { data, error } = await client
.from('fishing_permits')
.select('*, waters ( id, name )')
.eq('id', permitId)
.single();
if (error) throw error;
return data;
},
async deletePermit(permitId: string) {
const { error } = await client
.from('fishing_permits')
.delete()
.eq('id', permitId);
if (error) throw error;
},
// =====================================================
// Inspectors
// =====================================================

View File

@@ -3,7 +3,6 @@
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';
@@ -17,45 +16,82 @@ import {
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import { CreateNewsletterSchema } from '../schema/newsletter.schema';
import { createNewsletter } from '../server/actions/newsletter-actions';
import {
CreateNewsletterSchema,
UpdateNewsletterSchema,
} from '../schema/newsletter.schema';
import {
createNewsletter,
updateNewsletter,
} from '../server/actions/newsletter-actions';
interface Props {
accountId: string;
account: string;
newsletterId?: string;
initialData?: {
subject: string;
bodyHtml: string;
bodyText: string;
scheduledAt: string;
};
}
export function CreateNewsletterForm({ accountId, account }: Props) {
export function CreateNewsletterForm({
accountId,
account,
newsletterId,
initialData,
}: Props) {
const router = useRouter();
const isEdit = Boolean(newsletterId);
const form = useForm({
resolver: zodResolver(CreateNewsletterSchema),
resolver: zodResolver(
isEdit ? UpdateNewsletterSchema : CreateNewsletterSchema,
),
defaultValues: {
accountId,
subject: '',
bodyHtml: '',
bodyText: '',
scheduledAt: '',
...(isEdit ? { newsletterId } : { accountId }),
subject: initialData?.subject ?? '',
bodyHtml: initialData?.bodyHtml ?? '',
bodyText: initialData?.bodyText ?? '',
scheduledAt: initialData?.scheduledAt ?? '',
},
});
const { execute, isPending } = useAction(createNewsletter, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Newsletter erfolgreich erstellt');
router.push(`/home/${account}/newsletter`);
}
const { execute: execCreate, isPending: isCreating } = useActionWithToast(
createNewsletter,
{
successMessage: 'Newsletter erstellt',
errorMessage: 'Fehler beim Erstellen',
onSuccess: () => router.push(`/home/${account}/newsletter`),
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Erstellen des Newsletters');
);
const { execute: execUpdate, isPending: isUpdating } = useActionWithToast(
updateNewsletter,
{
successMessage: 'Newsletter aktualisiert',
errorMessage: 'Fehler beim Aktualisieren',
onSuccess: () =>
router.push(`/home/${account}/newsletter/${newsletterId}`),
},
});
);
const isPending = isCreating || isUpdating;
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => execute(data))}
onSubmit={form.handleSubmit((data) => {
if (isEdit && newsletterId) {
execUpdate({ ...data, newsletterId } as any);
} else {
execCreate({ ...data, accountId } as any);
}
})}
className="space-y-6"
>
<Card>
@@ -146,20 +182,15 @@ export function CreateNewsletterForm({ accountId, account }: Props) {
</Card>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
data-test="newsletter-cancel-btn"
>
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button
type="submit"
disabled={isPending}
data-test="newsletter-submit-btn"
>
{isPending ? 'Wird erstellt...' : 'Newsletter erstellen'}
<Button type="submit" disabled={isPending}>
{isPending
? 'Wird gespeichert...'
: isEdit
? 'Newsletter aktualisieren'
: 'Newsletter erstellen'}
</Button>
</div>
</form>

View File

@@ -18,6 +18,11 @@ export const CreateNewsletterSchema = z.object({
});
export type CreateNewsletterInput = z.infer<typeof CreateNewsletterSchema>;
export const UpdateNewsletterSchema = CreateNewsletterSchema.partial().extend({
newsletterId: z.string().uuid(),
});
export type UpdateNewsletterInput = z.infer<typeof UpdateNewsletterSchema>;
export const CreateTemplateSchema = z.object({
accountId: z.string().uuid(),
name: z.string().min(1),

View File

@@ -8,6 +8,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
CreateNewsletterSchema,
UpdateNewsletterSchema,
CreateTemplateSchema,
} from '../../schema/newsletter.schema';
import { createNewsletterApi } from '../api';
@@ -26,6 +27,19 @@ export const createNewsletter = authActionClient
return { success: true, data: result };
});
export const updateNewsletter = authActionClient
.inputSchema(UpdateNewsletterSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createNewsletterApi(client);
logger.info({ name: 'newsletter.update' }, 'Updating newsletter...');
const result = await api.updateNewsletter(input);
logger.info({ name: 'newsletter.update' }, 'Newsletter updated');
return { success: true, data: result };
});
export const createTemplate = authActionClient
.inputSchema(
z.object({

View File

@@ -2,7 +2,10 @@ import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type { CreateNewsletterInput } from '../schema/newsletter.schema';
import type {
CreateNewsletterInput,
UpdateNewsletterInput,
} from '../schema/newsletter.schema';
/* eslint-disable @typescript-eslint/no-explicit-any */
@@ -140,6 +143,26 @@ export function createNewsletterApi(client: SupabaseClient<Database>) {
return data;
},
async updateNewsletter(input: UpdateNewsletterInput) {
const update: Record<string, unknown> = {};
if (input.subject !== undefined) update.subject = input.subject;
if (input.bodyHtml !== undefined) update.body_html = input.bodyHtml;
if (input.bodyText !== undefined) update.body_text = input.bodyText;
if (input.templateId !== undefined)
update.template_id = input.templateId || null;
if (input.scheduledAt !== undefined)
update.scheduled_at = input.scheduledAt || null;
const { data, error } = await client
.from('newsletters')
.update(update)
.eq('id', input.newsletterId)
.select()
.single();
if (error) throw error;
return data;
},
async getNewsletter(newsletterId: string) {
const { data, error } = await client
.from('newsletters')

View File

@@ -18,8 +18,11 @@ import {
import { Input } from '@kit/ui/input';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import { CreateMemberClubSchema } from '../schema/verband.schema';
import { createClub } from '../server/actions/verband-actions';
import {
CreateMemberClubSchema,
UpdateMemberClubSchema,
} from '../schema/verband.schema';
import { createClub, updateClub } from '../server/actions/verband-actions';
interface CreateClubFormProps {
accountId: string;
@@ -38,7 +41,9 @@ export function CreateClubForm({
const isEdit = !!club;
const form = useForm({
resolver: zodResolver(CreateMemberClubSchema),
resolver: zodResolver(
isEdit ? UpdateMemberClubSchema : CreateMemberClubSchema,
),
defaultValues: {
accountId,
name: (club?.name as string) ?? '',
@@ -61,18 +66,37 @@ export function CreateClubForm({
},
});
const { execute, isPending } = useActionWithToast(createClub, {
successMessage: isEdit ? 'Verein aktualisiert' : 'Verein erstellt',
errorMessage: 'Fehler beim Speichern',
onSuccess: () => {
router.push(`/home/${account}/verband/clubs`);
const { execute: execCreate, isPending: isCreating } = useActionWithToast(
createClub,
{
successMessage: 'Verein erstellt',
errorMessage: 'Fehler beim Erstellen',
onSuccess: () => router.push(`/home/${account}/verband/clubs`),
},
});
);
const { execute: execUpdate, isPending: isUpdating } = useActionWithToast(
updateClub,
{
successMessage: 'Verein aktualisiert',
errorMessage: 'Fehler beim Aktualisieren',
onSuccess: () =>
router.push(`/home/${account}/verband/clubs/${String(club?.id)}`),
},
);
const isPending = isCreating || isUpdating;
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => execute(data))}
onSubmit={form.handleSubmit((data) => {
if (isEdit && club?.id) {
execUpdate({ ...data, clubId: String(club.id) } as any);
} else {
execCreate(data);
}
})}
className="space-y-6"
>
{/* Card 1: Grunddaten */}