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

@@ -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>

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"
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>

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 { 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">