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

@@ -83,7 +83,11 @@ export function CreateSessionDialog({ courseId }: Props) {
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="submit" disabled={isPending}> <Button
type="submit"
disabled={isPending}
data-test="session-submit-btn"
>
{isPending ? 'Wird erstellt...' : 'Termin erstellen'} {isPending ? 'Wird erstellt...' : 'Termin erstellen'}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -36,7 +36,12 @@ export function DeleteCourseButton({ courseId, accountSlug }: Props) {
return ( return (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={isPending}> <Button
variant="destructive"
size="sm"
disabled={isPending}
data-test="course-cancel-btn"
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Kurs absagen Kurs absagen
</Button> </Button>
@@ -50,8 +55,13 @@ export function DeleteCourseButton({ courseId, accountSlug }: Props) {
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel> <AlertDialogCancel data-test="course-cancel-dismiss-btn">
<AlertDialogAction onClick={() => execute({ courseId })}> Abbrechen
</AlertDialogCancel>
<AlertDialogAction
data-test="course-cancel-confirm-btn"
onClick={() => execute({ courseId })}
>
Absagen Absagen
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View File

@@ -0,0 +1,70 @@
'use client';
import { useState } from 'react';
import { Trash2 } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
interface DeleteConfirmButtonProps {
title: string;
description: string;
isPending?: boolean;
onConfirm: () => void;
}
export function DeleteConfirmButton({
title,
description,
isPending,
onConfirm,
}: DeleteConfirmButtonProps) {
const [open, setOpen] = useState(false);
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="sm"
data-test="file-delete-btn"
disabled={isPending}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Trash2 className="text-destructive h-4 w-4" />
</Button>
}
/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
onConfirm();
setOpen(false);
}}
>
{isPending ? 'Wird gelöscht...' : 'Löschen'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Upload } from 'lucide-react';
import { uploadFile } from '@kit/module-builder/actions/file-actions';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
interface FileUploadDialogProps {
accountId: string;
}
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
const ACCEPTED_TYPES = [
'image/*',
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/csv',
'text/plain',
'application/zip',
].join(',');
function formatFileSize(bytes: number): string {
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
return `${(bytes / 1024).toFixed(1)} KB`;
}
export function FileUploadDialog({ accountId }: FileUploadDialogProps) {
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<{
name: string;
type: string;
size: number;
base64: string;
} | null>(null);
const [error, setError] = useState<string | null>(null);
const { execute, isPending } = useActionWithToast(uploadFile, {
successMessage: 'Datei hochgeladen',
onSuccess: () => {
setOpen(false);
setSelectedFile(null);
setError(null);
router.refresh();
},
});
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setError(null);
const file = e.target.files?.[0];
if (!file) {
setSelectedFile(null);
return;
}
if (file.size > MAX_FILE_SIZE) {
setError('Die Datei darf maximal 10 MB groß sein.');
setSelectedFile(null);
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// Remove the data:...;base64, prefix
const base64 = result.split(',')[1] ?? '';
setSelectedFile({
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
base64,
});
};
reader.onerror = () => {
setError('Fehler beim Lesen der Datei.');
setSelectedFile(null);
};
reader.readAsDataURL(file);
},
[],
);
const handleUpload = useCallback(() => {
if (!selectedFile) return;
execute({
accountId,
fileName: selectedFile.name,
fileType: selectedFile.type,
fileSize: selectedFile.size,
base64: selectedFile.base64,
});
}, [accountId, execute, selectedFile]);
const handleOpenChange = useCallback(
(isOpen: boolean) => {
if (!isPending) {
setOpen(isOpen);
if (!isOpen) {
setSelectedFile(null);
setError(null);
}
}
},
[isPending],
);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger
render={
<Button size="sm" data-test="file-upload-btn">
<Upload className="mr-2 h-4 w-4" />
Datei hochladen
</Button>
}
/>
<DialogContent showCloseButton={!isPending}>
<DialogHeader>
<DialogTitle>Datei hochladen</DialogTitle>
<DialogDescription>
Wählen Sie eine Datei aus (max. 10 MB).
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_TYPES}
onChange={handleFileSelect}
className="file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 block w-full text-sm file:mr-4 file:rounded-md file:border-0 file:px-4 file:py-2 file:text-sm file:font-medium"
data-test="file-upload-input"
/>
{error && <p className="text-destructive text-sm">{error}</p>}
{selectedFile && (
<div className="bg-muted rounded-md p-3">
<p className="text-sm font-medium">{selectedFile.name}</p>
<p className="text-muted-foreground text-xs">
{selectedFile.type} &middot; {formatFileSize(selectedFile.size)}
</p>
</div>
)}
<Button
onClick={handleUpload}
disabled={!selectedFile || isPending}
data-test="file-upload-submit"
>
{isPending ? 'Wird hochgeladen...' : 'Hochladen'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,196 @@
'use client';
import { useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Download, FileIcon } from 'lucide-react';
import { deleteFile } from '@kit/module-builder/actions/file-actions';
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 { DeleteConfirmButton } from './delete-confirm-button';
interface FileRecord {
id: string;
file_name: string;
original_name: string;
mime_type: string;
file_size: number;
created_at: string;
storage_path: string;
publicUrl: string;
}
interface FilesTableProps {
files: FileRecord[];
pagination: { total: number; page: number; pageSize: number };
}
function formatFileSize(bytes: number): string {
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
return `${(bytes / 1024).toFixed(1)} KB`;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-AT', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function getMimeLabel(mimeType: string): string {
const map: Record<string, string> = {
'application/pdf': 'PDF',
'image/jpeg': 'JPEG',
'image/png': 'PNG',
'image/gif': 'GIF',
'image/webp': 'WebP',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
'DOCX',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX',
'text/csv': 'CSV',
'text/plain': 'TXT',
'application/zip': 'ZIP',
};
return map[mimeType] ?? mimeType.split('/').pop()?.toUpperCase() ?? 'Datei';
}
export function FilesTable({ files, pagination }: FilesTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { total, page, pageSize } = pagination;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const { execute: executeDelete, isPending: isDeleting } = useActionWithToast(
deleteFile,
{
successMessage: 'Datei gelöscht',
onSuccess: () => router.refresh(),
},
);
const handlePageChange = useCallback(
(newPage: number) => {
const params = new URLSearchParams(searchParams.toString());
params.set('page', String(newPage));
router.push(`?${params.toString()}`);
},
[router, searchParams],
);
return (
<Card>
<CardHeader>
<CardTitle>Dateien ({total})</CardTitle>
</CardHeader>
<CardContent>
{files.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<FileIcon className="text-muted-foreground mb-4 h-12 w-12" />
<h3 className="text-lg font-semibold">Keine Dateien vorhanden</h3>
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
Laden Sie Ihre erste Datei hoch.
</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">Dateiname</th>
<th className="p-3 text-left font-medium">Typ</th>
<th className="p-3 text-right font-medium">Größe</th>
<th className="p-3 text-left font-medium">Hochgeladen</th>
<th className="p-3 text-right font-medium">Aktionen</th>
</tr>
</thead>
<tbody>
{files.map((file) => (
<tr key={file.id} className="hover:bg-muted/30 border-b">
<td className="max-w-[300px] truncate p-3 font-medium">
{file.original_name}
</td>
<td className="p-3">
<Badge variant="secondary">
{getMimeLabel(file.mime_type)}
</Badge>
</td>
<td className="text-muted-foreground p-3 text-right">
{formatFileSize(file.file_size)}
</td>
<td className="text-muted-foreground p-3">
{formatDate(file.created_at)}
</td>
<td className="p-3 text-right">
<div className="flex items-center justify-end gap-1">
<a
href={file.publicUrl}
target="_blank"
rel="noopener noreferrer"
download={file.original_name}
>
<Button
variant="ghost"
size="sm"
data-test="file-download-btn"
>
<Download className="h-4 w-4" />
</Button>
</a>
<DeleteConfirmButton
title="Datei löschen"
description="Möchten Sie diese Datei wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden."
isPending={isDeleting}
onConfirm={() => executeDelete({ fileId: file.id })}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<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)}
data-test="files-prev-page"
>
Zurück
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
data-test="files-next-page"
>
Weiter
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,70 @@
import { createModuleBuilderApi } from '@kit/module-builder/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { ListToolbar } from '@kit/ui/list-toolbar';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import { FileUploadDialog } from './file-upload-dialog';
import { FilesTable } from './files-table';
interface Props {
params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function FilesPage({ params, searchParams }: Props) {
const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createModuleBuilderApi(client);
const page = Number(search.page) || 1;
const pageSize = 25;
const result = await api.files.listFiles(acct.id, {
search: search.q as string,
page,
pageSize,
});
// Resolve public URLs for each file
const filesWithUrls = result.data.map((file) => ({
id: String(file.id),
file_name: String(file.file_name),
original_name: String(file.original_name),
mime_type: String(file.mime_type),
file_size: Number(file.file_size),
created_at: String(file.created_at),
storage_path: String(file.storage_path),
publicUrl: api.files.getPublicUrl(String(file.storage_path)),
}));
return (
<CmsPageShell
account={account}
title="Dateien"
description="Dateien hochladen und verwalten"
>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<ListToolbar searchPlaceholder="Datei suchen..." />
<FileUploadDialog accountId={acct.id} />
</div>
<FilesTable
files={filesWithUrls}
pagination={{ total: result.total, page, pageSize }}
/>
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,45 @@
import { createFischereiApi } from '@kit/fischerei/api';
import {
FischereiTabNavigation,
CreateCompetitionForm,
} from '@kit/fischerei/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 }>;
}
export default async function NewCompetitionPage({ params }: Props) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createFischereiApi(client);
const watersResult = await api.listWaters(acct.id, { pageSize: 200 });
const waters = watersResult.data.map((w: Record<string, unknown>) => ({
id: String(w.id),
name: String(w.name),
}));
return (
<CmsPageShell account={account} title="Neuer Wettbewerb">
<FischereiTabNavigation account={account} activeTab="competitions" />
<CreateCompetitionForm
accountId={acct.id}
account={account}
waters={waters}
/>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,41 @@
import { createFischereiApi } from '@kit/fischerei/api';
import {
FischereiTabNavigation,
CreateLeaseForm,
} from '@kit/fischerei/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 }>;
}
export default async function NewLeasePage({ params }: Props) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createFischereiApi(client);
const watersResult = await api.listWaters(acct.id, { pageSize: 200 });
const waters = watersResult.data.map((w: Record<string, unknown>) => ({
id: String(w.id),
name: String(w.name),
}));
return (
<CmsPageShell account={account} title="Neue Pacht">
<FischereiTabNavigation account={account} activeTab="leases" />
<CreateLeaseForm accountId={acct.id} account={account} waters={waters} />
</CmsPageShell>
);
}

View File

@@ -1,9 +1,14 @@
import Link from 'next/link';
import { Plus } from 'lucide-react';
import { createFischereiApi } from '@kit/fischerei/api'; import { createFischereiApi } from '@kit/fischerei/api';
import { import {
FischereiTabNavigation, FischereiTabNavigation,
LeasesDataTable, LeasesDataTable,
} from '@kit/fischerei/components'; } from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { ListToolbar } from '@kit/ui/list-toolbar'; import { ListToolbar } from '@kit/ui/list-toolbar';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
@@ -56,12 +61,20 @@ export default async function LeasesPage({ params, searchParams }: Props) {
<CmsPageShell account={account} title="Fischerei - Pachten"> <CmsPageShell account={account} title="Fischerei - Pachten">
<FischereiTabNavigation account={account} activeTab="leases" /> <FischereiTabNavigation account={account} activeTab="leases" />
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">Pachten</h1> <h1 className="text-2xl font-bold">Pachten</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Gewässerpachtverträge verwalten Gewässerpachtverträge verwalten
</p> </p>
</div> </div>
<Link href={`/home/${account}/fischerei/leases/new`}>
<Button size="sm" data-test="leases-new-btn">
<Plus className="mr-2 h-4 w-4" />
Neue Pacht
</Button>
</Link>
</div>
<ListToolbar <ListToolbar
showSearch={false} showSearch={false}
filters={[ filters={[

View File

@@ -0,0 +1,41 @@
import { createFischereiApi } from '@kit/fischerei/api';
import {
FischereiTabNavigation,
CreatePermitForm,
} from '@kit/fischerei/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 }>;
}
export default async function NewPermitPage({ params }: Props) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createFischereiApi(client);
const watersResult = await api.listWaters(acct.id, { pageSize: 200 });
const waters = watersResult.data.map((w: Record<string, unknown>) => ({
id: String(w.id),
name: String(w.name),
}));
return (
<CmsPageShell account={account} title="Neuer Erlaubnisschein">
<FischereiTabNavigation account={account} activeTab="permits" />
<CreatePermitForm accountId={acct.id} account={account} waters={waters} />
</CmsPageShell>
);
}

View File

@@ -1,9 +1,14 @@
import Link from 'next/link';
import { Plus } from 'lucide-react';
import { createFischereiApi } from '@kit/fischerei/api'; import { createFischereiApi } from '@kit/fischerei/api';
import { import {
FischereiTabNavigation, FischereiTabNavigation,
PermitsDataTable, PermitsDataTable,
} from '@kit/fischerei/components'; } from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
@@ -31,12 +36,20 @@ export default async function PermitsPage({ params }: Props) {
<CmsPageShell account={account} title="Fischerei - Erlaubnisscheine"> <CmsPageShell account={account} title="Fischerei - Erlaubnisscheine">
<FischereiTabNavigation account={account} activeTab="permits" /> <FischereiTabNavigation account={account} activeTab="permits" />
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">Erlaubnisscheine</h1> <h1 className="text-2xl font-bold">Erlaubnisscheine</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Erlaubnisscheine und Gewässerkarten verwalten Erlaubnisscheine und Gewässerkarten verwalten
</p> </p>
</div> </div>
<Link href={`/home/${account}/fischerei/permits/new`}>
<Button size="sm" data-test="permits-new-btn">
<Plus className="mr-2 h-4 w-4" />
Neuer Erlaubnisschein
</Button>
</Link>
</div>
<PermitsDataTable <PermitsDataTable
data={permits as Array<Record<string, unknown>>} data={permits as Array<Record<string, unknown>>}
accountId={acct.id} accountId={acct.id}

View File

@@ -124,6 +124,7 @@ export function RecordDetailClient({
variant="outline" variant="outline"
size="sm" size="sm"
disabled={isBusy} disabled={isBusy}
data-test="record-lock-btn"
onClick={() => onClick={() =>
execLock({ execLock({
recordId: record.id, recordId: record.id,
@@ -147,7 +148,12 @@ export function RecordDetailClient({
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={isBusy}> <Button
variant="destructive"
size="sm"
disabled={isBusy}
data-test="record-delete-btn"
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Löschen Löschen
</Button> </Button>
@@ -163,6 +169,7 @@ export function RecordDetailClient({
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel> <AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
data-test="record-delete-confirm-btn"
onClick={() => onClick={() =>
execDelete({ execDelete({
recordId: record.id, recordId: record.id,

View File

@@ -48,7 +48,11 @@ export function DeleteModuleButton({
return ( return (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="destructive" disabled={isDeleting || isPending}> <Button
variant="destructive"
disabled={isDeleting || isPending}
data-test="module-archive-btn"
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Modul archivieren Modul archivieren
</Button> </Button>
@@ -64,7 +68,10 @@ export function DeleteModuleButton({
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel> <AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction onClick={() => execute({ moduleId })}> <AlertDialogAction
data-test="module-archive-confirm-btn"
onClick={() => execute({ moduleId })}
>
Archivieren Archivieren
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View File

@@ -0,0 +1,186 @@
'use client';
import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Shield } from 'lucide-react';
import { upsertModulePermission } from '@kit/module-builder/actions/module-actions';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Checkbox } from '@kit/ui/checkbox';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
const PERMISSION_COLUMNS = [
{ key: 'canRead', dbKey: 'can_read', label: 'Lesen' },
{ key: 'canInsert', dbKey: 'can_insert', label: 'Erstellen' },
{ key: 'canUpdate', dbKey: 'can_update', label: 'Bearbeiten' },
{ key: 'canDelete', dbKey: 'can_delete', label: 'Löschen' },
{ key: 'canExport', dbKey: 'can_export', label: 'Export' },
{ key: 'canImport', dbKey: 'can_import', label: 'Import' },
{ key: 'canLock', dbKey: 'can_lock', label: 'Sperren' },
{ key: 'canBulkEdit', dbKey: 'can_bulk_edit', label: 'Massenbearbeitung' },
{ key: 'canManage', dbKey: 'can_manage', label: 'Verwalten' },
{ key: 'canPrint', dbKey: 'can_print', label: 'Drucken' },
] as const;
type PermKey = (typeof PERMISSION_COLUMNS)[number]['key'];
interface ModulePermissionsProps {
moduleId: string;
roles: Array<{ name: string }>;
permissions: Array<{ role: string; [key: string]: unknown }>;
}
function buildInitialState(
roles: Array<{ name: string }>,
permissions: Array<{ role: string; [key: string]: unknown }>,
): Record<string, Record<PermKey, boolean>> {
const state: Record<string, Record<PermKey, boolean>> = {};
for (const role of roles) {
const existing = permissions.find((p) => p.role === role.name);
state[role.name] = {} as Record<PermKey, boolean>;
for (const col of PERMISSION_COLUMNS) {
state[role.name]![col.key] = Boolean(existing?.[col.dbKey]);
}
}
return state;
}
export function ModulePermissions({
moduleId,
roles,
permissions,
}: ModulePermissionsProps) {
const router = useRouter();
const [state, setState] = useState(() =>
buildInitialState(roles, permissions),
);
const { execute, isPending } = useActionWithToast(upsertModulePermission, {
successMessage: 'Berechtigung gespeichert',
errorMessage: 'Fehler beim Speichern',
onSuccess: () => router.refresh(),
});
const toggle = useCallback(
(roleName: string, permKey: PermKey, checked: boolean) => {
setState((prev) => {
const current = prev[roleName] ?? ({} as Record<PermKey, boolean>);
return {
...prev,
[roleName]: {
...current,
[permKey]: checked,
},
};
});
},
[],
);
function handleSave(roleName: string) {
const perms = state[roleName];
if (!perms) return;
execute({
moduleId,
role: roleName,
canRead: perms.canRead,
canInsert: perms.canInsert,
canUpdate: perms.canUpdate,
canDelete: perms.canDelete,
canExport: perms.canExport,
canImport: perms.canImport,
canLock: perms.canLock,
canBulkEdit: perms.canBulkEdit,
canManage: perms.canManage,
canPrint: perms.canPrint,
});
}
if (roles.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Berechtigungen
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
Keine Rollen vorhanden. Bitte erstellen Sie zuerst Rollen.
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Berechtigungen
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="p-3 text-left">Rolle</th>
{PERMISSION_COLUMNS.map((col) => (
<th key={col.key} className="p-3 text-center text-xs">
{col.label}
</th>
))}
<th className="p-3 text-right" />
</tr>
</thead>
<tbody>
{roles.map((role) => {
const perms = state[role.name];
return (
<tr
key={role.name}
className="hover:bg-muted/30 border-b last:border-b-0"
>
<td className="p-3 font-medium">{role.name}</td>
{PERMISSION_COLUMNS.map((col) => (
<td key={col.key} className="p-3 text-center">
<Checkbox
checked={perms?.[col.key] ?? false}
onCheckedChange={(checked) =>
toggle(role.name, col.key, Boolean(checked))
}
/>
</td>
))}
<td className="p-3 text-right">
<Button
type="button"
size="sm"
variant="outline"
disabled={isPending}
onClick={() => handleSave(role.name)}
>
Speichern
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,239 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Link2, Plus, Trash2 } from 'lucide-react';
import {
createModuleRelation,
deleteModuleRelation,
} from '@kit/module-builder/actions/module-actions';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import { Label } from '@kit/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kit/ui/select';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
const RELATION_TYPES = [
{ value: 'has_one', label: 'Hat eins (has_one)' },
{ value: 'has_many', label: 'Hat viele (has_many)' },
{ value: 'belongs_to', label: 'Gehört zu (belongs_to)' },
] as const;
interface Relation {
id: string;
source_module_id: string;
target_module_id: string;
source_field_id: string;
target_field_id: string | null;
relation_type: string;
source_module?: { id: string; display_name: string } | null;
target_module?: { id: string; display_name: string } | null;
source_field?: { id: string; name: string; display_name: string } | null;
}
interface ModuleRelationsProps {
moduleId: string;
fields: Array<{ id: string; name: string; display_name: string }>;
allModules: Array<{ id: string; display_name: string }>;
relations: Relation[];
}
export function ModuleRelations({
moduleId,
fields,
allModules,
relations,
}: ModuleRelationsProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [sourceFieldId, setSourceFieldId] = useState('');
const [targetModuleId, setTargetModuleId] = useState('');
const [relationType, setRelationType] = useState('');
const { execute: executeCreate, isPending: isCreating } = useActionWithToast(
createModuleRelation,
{
successMessage: 'Verknüpfung erstellt',
errorMessage: 'Fehler beim Erstellen',
onSuccess: () => {
setOpen(false);
resetForm();
router.refresh();
},
},
);
const { execute: executeDelete, isPending: isDeleting } = useActionWithToast(
deleteModuleRelation,
{
successMessage: 'Verknüpfung gelöscht',
errorMessage: 'Fehler beim Löschen',
onSuccess: () => router.refresh(),
},
);
function resetForm() {
setSourceFieldId('');
setTargetModuleId('');
setRelationType('');
}
function handleCreate() {
if (!sourceFieldId || !targetModuleId || !relationType) return;
executeCreate({
sourceModuleId: moduleId,
sourceFieldId,
targetModuleId,
relationType: relationType as 'has_one' | 'has_many' | 'belongs_to',
});
}
function getRelationLabel(relation: Relation) {
const fieldName =
relation.source_field?.display_name ?? relation.source_field?.name ?? '?';
const targetName = relation.target_module?.display_name ?? '?';
const typeLbl =
RELATION_TYPES.find((t) => t.value === relation.relation_type)?.label ??
relation.relation_type;
return `${fieldName}${targetName} (${typeLbl})`;
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Link2 className="h-4 w-4" />
Verknüpfungen ({relations.length})
</CardTitle>
<Dialog
open={open}
onOpenChange={(v) => {
setOpen(v);
if (!v) resetForm();
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Neue Verknüpfung
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Neue Verknüpfung erstellen</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Quellfeld</Label>
<Select value={sourceFieldId} onValueChange={setSourceFieldId}>
<SelectTrigger>
<SelectValue placeholder="Feld auswählen" />
</SelectTrigger>
<SelectContent>
{fields.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.display_name} ({f.name})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Zielmodul</Label>
<Select
value={targetModuleId}
onValueChange={setTargetModuleId}
>
<SelectTrigger>
<SelectValue placeholder="Modul auswählen" />
</SelectTrigger>
<SelectContent>
{allModules.map((m) => (
<SelectItem key={m.id} value={m.id}>
{String(m.display_name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Beziehungstyp</Label>
<Select value={relationType} onValueChange={setRelationType}>
<SelectTrigger>
<SelectValue placeholder="Typ auswählen" />
</SelectTrigger>
<SelectContent>
{RELATION_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
onClick={handleCreate}
disabled={
isCreating ||
!sourceFieldId ||
!targetModuleId ||
!relationType
}
>
{isCreating ? 'Wird erstellt...' : 'Erstellen'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
{relations.length === 0 ? (
<p className="text-muted-foreground text-sm">
Noch keine Verknüpfungen definiert.
</p>
) : (
<div className="space-y-2">
{relations.map((rel) => (
<div
key={rel.id}
className="bg-muted/30 flex items-center justify-between rounded-md border p-3"
>
<span className="text-sm">{getRelationLabel(rel)}</span>
<Button
variant="ghost"
size="sm"
disabled={isDeleting}
onClick={() => executeDelete({ relationId: rel.id })}
>
<Trash2 className="text-destructive h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -126,7 +126,7 @@ export function ModuleSettingsForm({
type="checkbox" type="checkbox"
name={key} name={key}
defaultChecked={isEnabled} defaultChecked={isEnabled}
className="h-4 w-4 rounded border-gray-300" className="border-input h-4 w-4 rounded"
/> />
<Badge variant={isEnabled ? 'default' : 'secondary'}> <Badge variant={isEnabled ? 'default' : 'secondary'}>
{label} {label}
@@ -136,7 +136,11 @@ export function ModuleSettingsForm({
})} })}
</div> </div>
<Button type="submit" disabled={isPending}> <Button
type="submit"
disabled={isPending}
data-test="module-settings-save-btn"
>
{isPending ? 'Wird gespeichert...' : 'Einstellungen speichern'} {isPending ? 'Wird gespeichert...' : 'Einstellungen speichern'}
</Button> </Button>
</form> </form>

View File

@@ -1,4 +1,4 @@
import { List, Shield } from 'lucide-react'; import { Link2, List } from 'lucide-react';
import { createModuleBuilderApi } from '@kit/module-builder/api'; import { createModuleBuilderApi } from '@kit/module-builder/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
@@ -8,6 +8,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { DeleteModuleButton } from './delete-module-button'; import { DeleteModuleButton } from './delete-module-button';
import { ModulePermissions } from './module-permissions';
import { ModuleRelations } from './module-relations';
import { ModuleSettingsForm } from './module-settings-form'; import { ModuleSettingsForm } from './module-settings-form';
interface ModuleSettingsPageProps { interface ModuleSettingsPageProps {
@@ -44,6 +46,30 @@ export default async function ModuleSettingsPage({
features[key] = Boolean(mod[key]); features[key] = Boolean(mod[key]);
} }
// Fetch roles, permissions, relations, and all modules in parallel
const [rolesResult, permissions, relations, allModules] = await Promise.all([
client.from('roles').select('name').order('hierarchy_level'),
api.modules.listPermissions(moduleId),
api.modules.listRelations(moduleId),
api.modules.listModules(String(mod.account_id)).then((mods) =>
mods.map((m) => ({
id: String(m.id),
display_name: String(m.display_name),
})),
),
]);
const roles: Array<{ name: string }> = (rolesResult.data ?? []).map((r) => ({
name: String(r.name),
}));
// Map fields for the relations component
const fieldOptions = fields.map((f) => ({
id: String(f.id),
name: String(f.name),
display_name: String(f.display_name),
}));
return ( return (
<CmsPageShell <CmsPageShell
account={account} account={account}
@@ -125,20 +151,19 @@ export default async function ModuleSettingsPage({
</Card> </Card>
{/* Permissions */} {/* Permissions */}
<Card> <ModulePermissions
<CardHeader> moduleId={moduleId}
<CardTitle className="flex items-center gap-2"> roles={roles}
<Shield className="h-4 w-4" /> permissions={permissions}
Berechtigungen />
</CardTitle>
</CardHeader> {/* Relations */}
<CardContent> <ModuleRelations
<p className="text-muted-foreground text-sm"> moduleId={moduleId}
Modulspezifische Berechtigungen pro Rolle können hier konfiguriert fields={fieldOptions}
werden. allModules={allModules}
</p> relations={relations}
</CardContent> />
</Card>
{/* Danger Zone */} {/* Danger Zone */}
<Card className="border-destructive/50"> <Card className="border-destructive/50">

View File

@@ -25,6 +25,9 @@
"advancedFilter": "Erweiterter Filter", "advancedFilter": "Erweiterter Filter",
"clearFilters": "Filter zurücksetzen", "clearFilters": "Filter zurücksetzen",
"noRecords": "Keine Datensätze gefunden", "noRecords": "Keine Datensätze gefunden",
"paginationSummary": "{total} Datensätze — Seite {page} von {totalPages}",
"paginationPrevious": "← Zurück",
"paginationNext": "Weiter →",
"newRecord": "Neuer Datensatz", "newRecord": "Neuer Datensatz",
"editRecord": "Datensatz bearbeiten", "editRecord": "Datensatz bearbeiten",
"deleteRecord": "Datensatz löschen", "deleteRecord": "Datensatz löschen",

View File

@@ -58,6 +58,11 @@
"newVersionAvailableDescription": "Eine neue Version der Anwendung ist verfügbar. Bitte laden Sie die Seite neu, um die neuesten Aktualisierungen zu erhalten.", "newVersionAvailableDescription": "Eine neue Version der Anwendung ist verfügbar. Bitte laden Sie die Seite neu, um die neuesten Aktualisierungen zu erhalten.",
"newVersionSubmitButton": "Neu laden und aktualisieren", "newVersionSubmitButton": "Neu laden und aktualisieren",
"back": "Zurück", "back": "Zurück",
"search": "Suchen",
"searchPlaceholder": "Suchen...",
"previous": "Zurück",
"next": "Weiter",
"recordCount": "{total} Datensätze",
"routes": { "routes": {
"home": "Startseite", "home": "Startseite",
"account": "Konto", "account": "Konto",

View File

@@ -25,6 +25,9 @@
"advancedFilter": "Advanced Filter", "advancedFilter": "Advanced Filter",
"clearFilters": "Clear Filters", "clearFilters": "Clear Filters",
"noRecords": "No records found", "noRecords": "No records found",
"paginationSummary": "{total} records — Page {page} of {totalPages}",
"paginationPrevious": "← Previous",
"paginationNext": "Next →",
"newRecord": "New Record", "newRecord": "New Record",
"editRecord": "Edit Record", "editRecord": "Edit Record",
"deleteRecord": "Delete Record", "deleteRecord": "Delete Record",

View File

@@ -58,6 +58,11 @@
"newVersionAvailableDescription": "A new version of the app is available. It is recommended to refresh the page to get the latest updates and avoid any issues.", "newVersionAvailableDescription": "A new version of the app is available. It is recommended to refresh the page to get the latest updates and avoid any issues.",
"newVersionSubmitButton": "Reload and Update", "newVersionSubmitButton": "Reload and Update",
"back": "Back", "back": "Back",
"search": "Search",
"searchPlaceholder": "Search...",
"previous": "Previous",
"next": "Next",
"recordCount": "{total} records",
"routes": { "routes": {
"home": "Home", "home": "Home",
"account": "Account", "account": "Account",

View File

@@ -16,6 +16,7 @@ import {
FormMessage, FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { Textarea } from '@kit/ui/textarea';
import { useActionWithToast } from '@kit/ui/use-action-with-toast'; import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import { import {
@@ -151,10 +152,7 @@ export function CreateCourseForm({
<FormItem> <FormItem>
<FormLabel>Beschreibung</FormLabel> <FormLabel>Beschreibung</FormLabel>
<FormControl> <FormControl>
<textarea <Textarea {...field} />
{...field}
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -327,10 +325,7 @@ export function CreateCourseForm({
<FormItem> <FormItem>
<FormLabel>Notizen</FormLabel> <FormLabel>Notizen</FormLabel>
<FormControl> <FormControl>
<textarea <Textarea {...field} />
{...field}
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </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 { CatchBooksDataTable } from './catch-books-data-table';
export { CompetitionsDataTable } from './competitions-data-table'; export { CompetitionsDataTable } from './competitions-data-table';
export { LeasesDataTable } from './leases-data-table'; export { LeasesDataTable } from './leases-data-table';
export { CreateLeaseForm } from './create-lease-form';
export { PermitsDataTable } from './permits-data-table'; 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, FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { Textarea } from '@kit/ui/textarea';
import { useActionWithToast } from '@kit/ui/use-action-with-toast'; import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import { CreateMemberSchema } from '../schema/member.schema'; import { CreateMemberSchema } from '../schema/member.schema';
@@ -585,10 +586,7 @@ export function CreateMemberForm({
<FormItem> <FormItem>
<FormLabel>Notizen</FormLabel> <FormLabel>Notizen</FormLabel>
<FormControl> <FormControl>
<textarea <Textarea {...field} />
{...field}
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@@ -18,6 +18,7 @@ import {
} from '@kit/ui/form'; } from '@kit/ui/form';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner'; import { toast } from '@kit/ui/sonner';
import { Textarea } from '@kit/ui/textarea';
import { UpdateMemberSchema } from '../schema/member.schema'; import { UpdateMemberSchema } from '../schema/member.schema';
import { updateMember } from '../server/actions/member-actions'; import { updateMember } from '../server/actions/member-actions';
@@ -479,11 +480,7 @@ export function EditMemberForm({ member, account, accountId }: Props) {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<textarea <Textarea {...field} rows={4} />
{...field}
rows={4}
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { formatDate } from '@kit/shared/dates'; import { formatDate } from '@kit/shared/dates';
import type { CmsFieldType } from '../schema/module.schema'; import type { CmsFieldType } from '../schema/module.schema';
@@ -56,6 +58,7 @@ export function ModuleTable({
onSelectionChange, onSelectionChange,
currentSort, currentSort,
}: ModuleTableProps) { }: ModuleTableProps) {
const t = useTranslations('cms.modules');
const visibleFields = fields const visibleFields = fields
.filter((f) => f.show_in_table) .filter((f) => f.show_in_table)
.sort((a, b) => a.sort_order - b.sort_order); .sort((a, b) => a.sort_order - b.sort_order);
@@ -137,7 +140,7 @@ export function ModuleTable({
colSpan={visibleFields.length + (onSelectionChange ? 1 : 0)} colSpan={visibleFields.length + (onSelectionChange ? 1 : 0)}
className="text-muted-foreground p-8 text-center" className="text-muted-foreground p-8 text-center"
> >
Keine Datensätze gefunden {t('noRecords')}
</td> </td>
</tr> </tr>
) : ( ) : (
@@ -183,8 +186,11 @@ export function ModuleTable({
{pagination.totalPages > 1 && ( {pagination.totalPages > 1 && (
<div className="flex items-center justify-between px-2"> <div className="flex items-center justify-between px-2">
<span className="text-muted-foreground text-sm"> <span className="text-muted-foreground text-sm">
{pagination.total} Datensätze Seite {pagination.page} von{' '} {t('paginationSummary', {
{pagination.totalPages} total: pagination.total,
page: pagination.page,
totalPages: pagination.totalPages,
})}
</span> </span>
<div className="flex gap-1"> <div className="flex gap-1">
<button <button
@@ -192,14 +198,14 @@ export function ModuleTable({
disabled={pagination.page <= 1} disabled={pagination.page <= 1}
className="hover:bg-muted rounded border px-3 py-1 text-sm disabled:opacity-50" className="hover:bg-muted rounded border px-3 py-1 text-sm disabled:opacity-50"
> >
Zurück {t('paginationPrevious')}
</button> </button>
<button <button
onClick={() => onPageChange(pagination.page + 1)} onClick={() => onPageChange(pagination.page + 1)}
disabled={pagination.page >= pagination.totalPages} disabled={pagination.page >= pagination.totalPages}
className="hover:bg-muted rounded border px-3 py-1 text-sm disabled:opacity-50" className="hover:bg-muted rounded border px-3 py-1 text-sm disabled:opacity-50"
> >
Weiter {t('paginationNext')}
</button> </button>
</div> </div>
</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'; 'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action'; import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger'; import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
@@ -69,3 +71,125 @@ export const deleteModule = authActionClient
return { success: true }; 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 type { Database } from '@kit/supabase/database';
import { createAuditService } from './services/audit.service'; import { createAuditService } from './services/audit.service';
import { createFileService } from './services/file.service';
import { createModuleDefinitionService } from './services/module-definition.service'; import { createModuleDefinitionService } from './services/module-definition.service';
import { createModuleQueryService } from './services/module-query.service'; import { createModuleQueryService } from './services/module-query.service';
import { createRecordCrudService } from './services/record-crud.service'; import { createRecordCrudService } from './services/record-crud.service';
@@ -20,5 +21,6 @@ export function createModuleBuilderApi(client: SupabaseClient<Database>) {
query: createModuleQueryService(client), query: createModuleQueryService(client),
records: createRecordCrudService(client), records: createRecordCrudService(client),
audit: createAuditService(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; 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, FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { Textarea } from '@kit/ui/textarea';
import { useActionWithToast } from '@kit/ui/use-action-with-toast'; import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import { import {
@@ -119,10 +120,10 @@ export function CreateNewsletterForm({
<FormItem> <FormItem>
<FormLabel>Inhalt (HTML) *</FormLabel> <FormLabel>Inhalt (HTML) *</FormLabel>
<FormControl> <FormControl>
<textarea <Textarea
{...field} {...field}
rows={12} 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>" placeholder="<h1>Hallo!</h1><p>Ihr Newsletter-Inhalt...</p>"
data-test="newsletter-body-input" data-test="newsletter-body-input"
/> />
@@ -138,10 +139,9 @@ export function CreateNewsletterForm({
<FormItem> <FormItem>
<FormLabel>Nur-Text-Version (optional)</FormLabel> <FormLabel>Nur-Text-Version (optional)</FormLabel>
<FormControl> <FormControl>
<textarea <Textarea
{...field} {...field}
rows={4} 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" placeholder="Nur-Text-Fallback für E-Mail-Clients ohne HTML-Unterstützung"
data-test="newsletter-text-input" data-test="newsletter-text-input"
/> />

View File

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

78
pnpm-lock.yaml generated
View File

@@ -539,7 +539,7 @@ importers:
version: 1.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 1.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@nosecone/next': '@nosecone/next':
specifier: 'catalog:' specifier: 'catalog:'
version: 1.3.1(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) version: 1.3.1(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
'@supabase/supabase-js': '@supabase/supabase-js':
specifier: 'catalog:' specifier: 'catalog:'
version: 2.101.0 version: 2.101.0
@@ -572,13 +572,16 @@ importers:
version: 3.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) version: 3.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
next-safe-action: next-safe-action:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-sitemap: next-sitemap:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.2.3(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) version: 4.2.3(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
next-themes: next-themes:
specifier: 'catalog:' specifier: 'catalog:'
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
papaparse:
specifier: 'catalog:'
version: 5.5.3
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.4 version: 19.2.4
@@ -628,6 +631,9 @@ importers:
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: 'catalog:' specifier: 'catalog:'
version: 4.2.2 version: 4.2.2
'@types/papaparse':
specifier: 'catalog:'
version: 5.5.2
'@types/react': '@types/react':
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.14 version: 19.2.14
@@ -720,7 +726,7 @@ importers:
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-intl: next-intl:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.8.3(@swc/helpers@0.5.20)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) version: 4.8.3(@swc/helpers@0.5.20)(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.4 version: 19.2.4
@@ -925,7 +931,7 @@ importers:
version: 19.2.14 version: 19.2.14
next-intl: next-intl:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.8.3(@swc/helpers@0.5.20)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) version: 4.8.3(@swc/helpers@0.5.20)(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.4 version: 19.2.4
@@ -995,10 +1001,10 @@ importers:
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-intl: next-intl:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.8.3(@swc/helpers@0.5.20)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) version: 4.8.3(@swc/helpers@0.5.20)(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
next-safe-action: next-safe-action:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-themes: next-themes:
specifier: 'catalog:' specifier: 'catalog:'
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -1061,7 +1067,7 @@ importers:
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-safe-action: next-safe-action:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.4 version: 19.2.4
@@ -1112,7 +1118,7 @@ importers:
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-intl: next-intl:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.8.3(@swc/helpers@0.5.20)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) version: 4.8.3(@swc/helpers@0.5.20)(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
react-hook-form: react-hook-form:
specifier: 'catalog:' specifier: 'catalog:'
version: 7.72.0(react@19.2.4) version: 7.72.0(react@19.2.4)
@@ -1154,7 +1160,7 @@ importers:
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-safe-action: next-safe-action:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.4 version: 19.2.4
@@ -1196,7 +1202,7 @@ importers:
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-safe-action: next-safe-action:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.4 version: 19.2.4
@@ -1235,7 +1241,7 @@ importers:
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-safe-action: next-safe-action:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.4 version: 19.2.4
@@ -1274,7 +1280,7 @@ importers:
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-safe-action: next-safe-action:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.4 version: 19.2.4
@@ -1316,7 +1322,7 @@ importers:
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-safe-action: next-safe-action:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.4 version: 19.2.4
@@ -1361,7 +1367,7 @@ importers:
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-safe-action: next-safe-action:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.4 version: 19.2.4
@@ -1412,7 +1418,7 @@ importers:
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-safe-action: next-safe-action:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
papaparse: papaparse:
specifier: 'catalog:' specifier: 'catalog:'
version: 5.5.3 version: 5.5.3
@@ -1452,9 +1458,12 @@ importers:
next: next:
specifier: 'catalog:' specifier: 'catalog:'
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-intl:
specifier: 'catalog:'
version: 4.8.3(@swc/helpers@0.5.20)(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
next-safe-action: next-safe-action:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.4 version: 19.2.4
@@ -1496,7 +1505,7 @@ importers:
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-safe-action: next-safe-action:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.4 version: 19.2.4
@@ -1536,7 +1545,7 @@ importers:
version: 1.7.0(react@19.2.4) version: 1.7.0(react@19.2.4)
next-intl: next-intl:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.8.3(@swc/helpers@0.5.20)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) version: 4.8.3(@swc/helpers@0.5.20)(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.4 version: 19.2.4
@@ -1581,7 +1590,7 @@ importers:
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-safe-action: next-safe-action:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.4 version: 19.2.4
@@ -1626,7 +1635,7 @@ importers:
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-safe-action: next-safe-action:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.4 version: 19.2.4
@@ -1711,10 +1720,10 @@ importers:
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-intl: next-intl:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.8.3(@swc/helpers@0.5.20)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) version: 4.8.3(@swc/helpers@0.5.20)(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
next-safe-action: next-safe-action:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.4 version: 19.2.4
@@ -1762,7 +1771,7 @@ importers:
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-safe-action: next-safe-action:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.4 version: 19.2.4
@@ -1780,7 +1789,7 @@ importers:
dependencies: dependencies:
next-intl: next-intl:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.8.3(@swc/helpers@0.5.20)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) version: 4.8.3(@swc/helpers@0.5.20)(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
devDependencies: devDependencies:
'@kit/shared': '@kit/shared':
specifier: workspace:* specifier: workspace:*
@@ -1951,7 +1960,7 @@ importers:
dependencies: dependencies:
next-safe-action: next-safe-action:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
devDependencies: devDependencies:
'@kit/auth': '@kit/auth':
specifier: workspace:* specifier: workspace:*
@@ -2015,7 +2024,7 @@ importers:
version: 1.7.0(react@19.2.4) version: 1.7.0(react@19.2.4)
next-safe-action: next-safe-action:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.4 version: 19.2.4
@@ -2043,6 +2052,9 @@ importers:
packages/shared: packages/shared:
dependencies: dependencies:
date-fns:
specifier: 'catalog:'
version: 4.1.0
next-runtime-env: next-runtime-env:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) version: 3.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
@@ -2162,10 +2174,10 @@ importers:
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-intl: next-intl:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.8.3(@swc/helpers@0.5.20)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) version: 4.8.3(@swc/helpers@0.5.20)(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
next-safe-action: next-safe-action:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-themes: next-themes:
specifier: 'catalog:' specifier: 'catalog:'
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -11539,7 +11551,7 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5 '@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1 fastq: 1.20.1
'@nosecone/next@1.3.1(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': '@nosecone/next@1.3.1(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))':
dependencies: dependencies:
next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
nosecone: 1.3.1 nosecone: 1.3.1
@@ -16677,7 +16689,7 @@ snapshots:
next-intl-swc-plugin-extractor@4.8.3: {} next-intl-swc-plugin-extractor@4.8.3: {}
next-intl@4.8.3(@swc/helpers@0.5.20)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3): next-intl@4.8.3(@swc/helpers@0.5.20)(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
dependencies: dependencies:
'@formatjs/intl-localematcher': 0.8.1 '@formatjs/intl-localematcher': 0.8.1
'@parcel/watcher': 2.5.6 '@parcel/watcher': 2.5.6
@@ -16716,14 +16728,14 @@ snapshots:
next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4 react: 19.2.4
next-safe-action@8.3.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4): next-safe-action@8.3.0(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies: dependencies:
deepmerge-ts: 7.1.5 deepmerge-ts: 7.1.5
next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
next-sitemap@4.2.3(next@16.2.1(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): next-sitemap@4.2.3(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)):
dependencies: dependencies:
'@corex/deepmerge': 4.0.43 '@corex/deepmerge': 4.0.43
'@next/env': 13.5.11 '@next/env': 13.5.11