diff --git a/apps/web/app/[locale]/home/[account]/courses/[courseId]/create-session-dialog.tsx b/apps/web/app/[locale]/home/[account]/courses/[courseId]/create-session-dialog.tsx index 0d01250a0..b759805b9 100644 --- a/apps/web/app/[locale]/home/[account]/courses/[courseId]/create-session-dialog.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/[courseId]/create-session-dialog.tsx @@ -83,7 +83,11 @@ export function CreateSessionDialog({ courseId }: Props) { - diff --git a/apps/web/app/[locale]/home/[account]/courses/[courseId]/delete-course-button.tsx b/apps/web/app/[locale]/home/[account]/courses/[courseId]/delete-course-button.tsx index 95a912516..0956c43ec 100644 --- a/apps/web/app/[locale]/home/[account]/courses/[courseId]/delete-course-button.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/[courseId]/delete-course-button.tsx @@ -36,7 +36,12 @@ export function DeleteCourseButton({ courseId, accountSlug }: Props) { return ( - @@ -50,8 +55,13 @@ export function DeleteCourseButton({ courseId, accountSlug }: Props) { - Abbrechen - execute({ courseId })}> + + Abbrechen + + execute({ courseId })} + > Absagen diff --git a/apps/web/app/[locale]/home/[account]/files/delete-confirm-button.tsx b/apps/web/app/[locale]/home/[account]/files/delete-confirm-button.tsx new file mode 100644 index 000000000..aeab17541 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/files/delete-confirm-button.tsx @@ -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 ( + + e.stopPropagation()} + > + + + } + /> + + + {title} + {description} + + + Abbrechen + { + onConfirm(); + setOpen(false); + }} + > + {isPending ? 'Wird gelöscht...' : 'Löschen'} + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/files/file-upload-dialog.tsx b/apps/web/app/[locale]/home/[account]/files/file-upload-dialog.tsx new file mode 100644 index 000000000..a784a7031 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/files/file-upload-dialog.tsx @@ -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(null); + const [open, setOpen] = useState(false); + const [selectedFile, setSelectedFile] = useState<{ + name: string; + type: string; + size: number; + base64: string; + } | null>(null); + const [error, setError] = useState(null); + + const { execute, isPending } = useActionWithToast(uploadFile, { + successMessage: 'Datei hochgeladen', + onSuccess: () => { + setOpen(false); + setSelectedFile(null); + setError(null); + router.refresh(); + }, + }); + + const handleFileSelect = useCallback( + (e: React.ChangeEvent) => { + 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 ( + + + + Datei hochladen + + } + /> + + + Datei hochladen + + Wählen Sie eine Datei aus (max. 10 MB). + + + +
+ + + {error &&

{error}

} + + {selectedFile && ( +
+

{selectedFile.name}

+

+ {selectedFile.type} · {formatFileSize(selectedFile.size)} +

+
+ )} + + +
+
+
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/files/files-table.tsx b/apps/web/app/[locale]/home/[account]/files/files-table.tsx new file mode 100644 index 000000000..28c664ee7 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/files/files-table.tsx @@ -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 = { + '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 ( + + + Dateien ({total}) + + + {files.length === 0 ? ( +
+ +

Keine Dateien vorhanden

+

+ Laden Sie Ihre erste Datei hoch. +

+
+ ) : ( +
+ + + + + + + + + + + + {files.map((file) => ( + + + + + + + + ))} + +
DateinameTypGrößeHochgeladenAktionen
+ {file.original_name} + + + {getMimeLabel(file.mime_type)} + + + {formatFileSize(file.file_size)} + + {formatDate(file.created_at)} + +
+ + + + executeDelete({ fileId: file.id })} + /> +
+
+
+ )} + + {totalPages > 1 && ( +
+

+ Seite {page} von {totalPages} ({total} Einträge) +

+
+ + +
+
+ )} +
+
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/files/page.tsx b/apps/web/app/[locale]/home/[account]/files/page.tsx new file mode 100644 index 000000000..319c12ab1 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/files/page.tsx @@ -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>; +} + +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 ; + + 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 ( + +
+
+ + +
+ + +
+
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/fischerei/competitions/new/page.tsx b/apps/web/app/[locale]/home/[account]/fischerei/competitions/new/page.tsx new file mode 100644 index 000000000..c25fa21ce --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/fischerei/competitions/new/page.tsx @@ -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 ; + + const api = createFischereiApi(client); + const watersResult = await api.listWaters(acct.id, { pageSize: 200 }); + + const waters = watersResult.data.map((w: Record) => ({ + id: String(w.id), + name: String(w.name), + })); + + return ( + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/fischerei/leases/new/page.tsx b/apps/web/app/[locale]/home/[account]/fischerei/leases/new/page.tsx new file mode 100644 index 000000000..37ca10cd7 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/fischerei/leases/new/page.tsx @@ -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 ; + + const api = createFischereiApi(client); + const watersResult = await api.listWaters(acct.id, { pageSize: 200 }); + + const waters = watersResult.data.map((w: Record) => ({ + id: String(w.id), + name: String(w.name), + })); + + return ( + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/fischerei/leases/page.tsx b/apps/web/app/[locale]/home/[account]/fischerei/leases/page.tsx index 6c4cf5746..5857cf6ee 100644 --- a/apps/web/app/[locale]/home/[account]/fischerei/leases/page.tsx +++ b/apps/web/app/[locale]/home/[account]/fischerei/leases/page.tsx @@ -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) {
-
-

Pachten

-

- Gewässerpachtverträge verwalten -

+
+
+

Pachten

+

+ Gewässerpachtverträge verwalten +

+
+ + +
; +} + +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 ; + + const api = createFischereiApi(client); + const watersResult = await api.listWaters(acct.id, { pageSize: 200 }); + + const waters = watersResult.data.map((w: Record) => ({ + id: String(w.id), + name: String(w.name), + })); + + return ( + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/fischerei/permits/page.tsx b/apps/web/app/[locale]/home/[account]/fischerei/permits/page.tsx index 8bcb1ea8e..a347dc40f 100644 --- a/apps/web/app/[locale]/home/[account]/fischerei/permits/page.tsx +++ b/apps/web/app/[locale]/home/[account]/fischerei/permits/page.tsx @@ -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) {
-
-

Erlaubnisscheine

-

- Erlaubnisscheine und Gewässerkarten verwalten -

+
+
+

Erlaubnisscheine

+

+ Erlaubnisscheine und Gewässerkarten verwalten +

+
+ + +
>} diff --git a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/[recordId]/record-detail-client.tsx b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/[recordId]/record-detail-client.tsx index cee71f64e..a549b2cdc 100644 --- a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/[recordId]/record-detail-client.tsx +++ b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/[recordId]/record-detail-client.tsx @@ -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({ - @@ -163,6 +169,7 @@ export function RecordDetailClient({ Abbrechen execDelete({ recordId: record.id, diff --git a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/delete-module-button.tsx b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/delete-module-button.tsx index d43ab4d82..95d8fdf48 100644 --- a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/delete-module-button.tsx +++ b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/delete-module-button.tsx @@ -48,7 +48,11 @@ export function DeleteModuleButton({ return ( - @@ -64,7 +68,10 @@ export function DeleteModuleButton({ Abbrechen - execute({ moduleId })}> + execute({ moduleId })} + > Archivieren diff --git a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/module-permissions.tsx b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/module-permissions.tsx new file mode 100644 index 000000000..3d4c9f342 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/module-permissions.tsx @@ -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> { + const state: Record> = {}; + + for (const role of roles) { + const existing = permissions.find((p) => p.role === role.name); + state[role.name] = {} as Record; + + 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); + 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 ( + + + + + Berechtigungen + + + +

+ Keine Rollen vorhanden. Bitte erstellen Sie zuerst Rollen. +

+
+
+ ); + } + + return ( + + + + + Berechtigungen + + + +
+ + + + + {PERMISSION_COLUMNS.map((col) => ( + + ))} + + + + {roles.map((role) => { + const perms = state[role.name]; + return ( + + + {PERMISSION_COLUMNS.map((col) => ( + + ))} + + + ); + })} + +
Rolle + {col.label} + +
{role.name} + + toggle(role.name, col.key, Boolean(checked)) + } + /> + + +
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/module-relations.tsx b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/module-relations.tsx new file mode 100644 index 000000000..3e988f3b8 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/module-relations.tsx @@ -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 ( + + + + + Verknüpfungen ({relations.length}) + + { + setOpen(v); + if (!v) resetForm(); + }} + > + + + + + + Neue Verknüpfung erstellen + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ + {relations.length === 0 ? ( +

+ Noch keine Verknüpfungen definiert. +

+ ) : ( +
+ {relations.map((rel) => ( +
+ {getRelationLabel(rel)} + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/module-settings-form.tsx b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/module-settings-form.tsx index 72a9ec049..fd0e67ef7 100644 --- a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/module-settings-form.tsx +++ b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/module-settings-form.tsx @@ -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" /> {label} @@ -136,7 +136,11 @@ export function ModuleSettingsForm({ })}
- diff --git a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/page.tsx b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/page.tsx index 39645334c..e52ee98f1 100644 --- a/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/page.tsx +++ b/apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/page.tsx @@ -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 ( {/* Permissions */} - - - - - Berechtigungen - - - -

- Modulspezifische Berechtigungen pro Rolle können hier konfiguriert - werden. -

-
-
+ + + {/* Relations */} + {/* Danger Zone */} diff --git a/apps/web/i18n/messages/de/cms.json b/apps/web/i18n/messages/de/cms.json index 1fb592984..234ed8e3e 100644 --- a/apps/web/i18n/messages/de/cms.json +++ b/apps/web/i18n/messages/de/cms.json @@ -25,6 +25,9 @@ "advancedFilter": "Erweiterter Filter", "clearFilters": "Filter zurücksetzen", "noRecords": "Keine Datensätze gefunden", + "paginationSummary": "{total} Datensätze — Seite {page} von {totalPages}", + "paginationPrevious": "← Zurück", + "paginationNext": "Weiter →", "newRecord": "Neuer Datensatz", "editRecord": "Datensatz bearbeiten", "deleteRecord": "Datensatz löschen", diff --git a/apps/web/i18n/messages/de/common.json b/apps/web/i18n/messages/de/common.json index b0f12f3ee..a10722e54 100644 --- a/apps/web/i18n/messages/de/common.json +++ b/apps/web/i18n/messages/de/common.json @@ -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.", "newVersionSubmitButton": "Neu laden und aktualisieren", "back": "Zurück", + "search": "Suchen", + "searchPlaceholder": "Suchen...", + "previous": "Zurück", + "next": "Weiter", + "recordCount": "{total} Datensätze", "routes": { "home": "Startseite", "account": "Konto", diff --git a/apps/web/i18n/messages/en/cms.json b/apps/web/i18n/messages/en/cms.json index 04f8acd71..d76e585e8 100644 --- a/apps/web/i18n/messages/en/cms.json +++ b/apps/web/i18n/messages/en/cms.json @@ -25,6 +25,9 @@ "advancedFilter": "Advanced Filter", "clearFilters": "Clear Filters", "noRecords": "No records found", + "paginationSummary": "{total} records — Page {page} of {totalPages}", + "paginationPrevious": "← Previous", + "paginationNext": "Next →", "newRecord": "New Record", "editRecord": "Edit Record", "deleteRecord": "Delete Record", diff --git a/apps/web/i18n/messages/en/common.json b/apps/web/i18n/messages/en/common.json index 33a2ddaea..af9ab96cb 100644 --- a/apps/web/i18n/messages/en/common.json +++ b/apps/web/i18n/messages/en/common.json @@ -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.", "newVersionSubmitButton": "Reload and Update", "back": "Back", + "search": "Search", + "searchPlaceholder": "Search...", + "previous": "Previous", + "next": "Next", + "recordCount": "{total} records", "routes": { "home": "Home", "account": "Account", diff --git a/packages/features/course-management/src/components/create-course-form.tsx b/packages/features/course-management/src/components/create-course-form.tsx index 49ca73a80..4915c3c9a 100644 --- a/packages/features/course-management/src/components/create-course-form.tsx +++ b/packages/features/course-management/src/components/create-course-form.tsx @@ -16,6 +16,7 @@ import { FormMessage, } from '@kit/ui/form'; import { Input } from '@kit/ui/input'; +import { Textarea } from '@kit/ui/textarea'; import { useActionWithToast } from '@kit/ui/use-action-with-toast'; import { @@ -151,10 +152,7 @@ export function CreateCourseForm({ Beschreibung -