feat: add file upload and management features; enhance pagination and permissions handling
This commit is contained in:
@@ -83,7 +83,11 @@ export function CreateSessionDialog({ courseId }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
data-test="session-submit-btn"
|
||||
>
|
||||
{isPending ? 'Wird erstellt...' : 'Termin erstellen'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -36,7 +36,12 @@ export function DeleteCourseButton({ courseId, accountSlug }: Props) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<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" />
|
||||
Kurs absagen
|
||||
</Button>
|
||||
@@ -50,8 +55,13 @@ export function DeleteCourseButton({ courseId, accountSlug }: Props) {
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => execute({ courseId })}>
|
||||
<AlertDialogCancel data-test="course-cancel-dismiss-btn">
|
||||
Abbrechen
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-test="course-cancel-confirm-btn"
|
||||
onClick={() => execute({ courseId })}
|
||||
>
|
||||
Absagen
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} · {formatFileSize(selectedFile.size)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || isPending}
|
||||
data-test="file-upload-submit"
|
||||
>
|
||||
{isPending ? 'Wird hochgeladen...' : 'Hochladen'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
196
apps/web/app/[locale]/home/[account]/files/files-table.tsx
Normal file
196
apps/web/app/[locale]/home/[account]/files/files-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
apps/web/app/[locale]/home/[account]/files/page.tsx
Normal file
70
apps/web/app/[locale]/home/[account]/files/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import {
|
||||
FischereiTabNavigation,
|
||||
LeasesDataTable,
|
||||
} from '@kit/fischerei/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { ListToolbar } from '@kit/ui/list-toolbar';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
@@ -56,11 +61,19 @@ export default async function LeasesPage({ params, searchParams }: Props) {
|
||||
<CmsPageShell account={account} title="Fischerei - Pachten">
|
||||
<FischereiTabNavigation account={account} activeTab="leases" />
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Pachten</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gewässerpachtverträge verwalten
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Pachten</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gewässerpachtverträge verwalten
|
||||
</p>
|
||||
</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
|
||||
showSearch={false}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { createFischereiApi } from '@kit/fischerei/api';
|
||||
import {
|
||||
FischereiTabNavigation,
|
||||
PermitsDataTable,
|
||||
} from '@kit/fischerei/components';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
@@ -31,11 +36,19 @@ export default async function PermitsPage({ params }: Props) {
|
||||
<CmsPageShell account={account} title="Fischerei - Erlaubnisscheine">
|
||||
<FischereiTabNavigation account={account} activeTab="permits" />
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Erlaubnisscheine</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Erlaubnisscheine und Gewässerkarten verwalten
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Erlaubnisscheine</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Erlaubnisscheine und Gewässerkarten verwalten
|
||||
</p>
|
||||
</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
|
||||
data={permits as Array<Record<string, unknown>>}
|
||||
|
||||
@@ -124,6 +124,7 @@ export function RecordDetailClient({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isBusy}
|
||||
data-test="record-lock-btn"
|
||||
onClick={() =>
|
||||
execLock({
|
||||
recordId: record.id,
|
||||
@@ -147,7 +148,12 @@ export function RecordDetailClient({
|
||||
|
||||
<AlertDialog>
|
||||
<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" />
|
||||
Löschen
|
||||
</Button>
|
||||
@@ -163,6 +169,7 @@ export function RecordDetailClient({
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-test="record-delete-confirm-btn"
|
||||
onClick={() =>
|
||||
execDelete({
|
||||
recordId: record.id,
|
||||
|
||||
@@ -48,7 +48,11 @@ export function DeleteModuleButton({
|
||||
return (
|
||||
<AlertDialog>
|
||||
<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" />
|
||||
Modul archivieren
|
||||
</Button>
|
||||
@@ -64,7 +68,10 @@ export function DeleteModuleButton({
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => execute({ moduleId })}>
|
||||
<AlertDialogAction
|
||||
data-test="module-archive-confirm-btn"
|
||||
onClick={() => execute({ moduleId })}
|
||||
>
|
||||
Archivieren
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -126,7 +126,7 @@ export function ModuleSettingsForm({
|
||||
type="checkbox"
|
||||
name={key}
|
||||
defaultChecked={isEnabled}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
className="border-input h-4 w-4 rounded"
|
||||
/>
|
||||
<Badge variant={isEnabled ? 'default' : 'secondary'}>
|
||||
{label}
|
||||
@@ -136,7 +136,11 @@ export function ModuleSettingsForm({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isPending}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
data-test="module-settings-save-btn"
|
||||
>
|
||||
{isPending ? 'Wird gespeichert...' : 'Einstellungen speichern'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { List, Shield } from 'lucide-react';
|
||||
import { Link2, List } from 'lucide-react';
|
||||
|
||||
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
||||
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 { DeleteModuleButton } from './delete-module-button';
|
||||
import { ModulePermissions } from './module-permissions';
|
||||
import { ModuleRelations } from './module-relations';
|
||||
import { ModuleSettingsForm } from './module-settings-form';
|
||||
|
||||
interface ModuleSettingsPageProps {
|
||||
@@ -44,6 +46,30 @@ export default async function ModuleSettingsPage({
|
||||
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 (
|
||||
<CmsPageShell
|
||||
account={account}
|
||||
@@ -125,20 +151,19 @@ export default async function ModuleSettingsPage({
|
||||
</Card>
|
||||
|
||||
{/* Permissions */}
|
||||
<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">
|
||||
Modulspezifische Berechtigungen pro Rolle können hier konfiguriert
|
||||
werden.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ModulePermissions
|
||||
moduleId={moduleId}
|
||||
roles={roles}
|
||||
permissions={permissions}
|
||||
/>
|
||||
|
||||
{/* Relations */}
|
||||
<ModuleRelations
|
||||
moduleId={moduleId}
|
||||
fields={fieldOptions}
|
||||
allModules={allModules}
|
||||
relations={relations}
|
||||
/>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-destructive/50">
|
||||
|
||||
Reference in New Issue
Block a user