Initial state for GitNexus analysis

This commit is contained in:
Zaid Marzguioui
2026-03-29 19:44:57 +02:00
parent 9d7c7f8030
commit 61ff48cb73
155 changed files with 23483 additions and 1722 deletions

View File

@@ -0,0 +1,273 @@
'use client';
import type { CmsFieldType } from '../schema/module.schema';
import { Input } from '@kit/ui/input';
import { Textarea } from '@kit/ui/textarea';
import { Checkbox } from '@kit/ui/checkbox';
import { Label } from '@kit/ui/label';
interface FieldRendererProps {
name: string;
displayName: string;
fieldType: CmsFieldType;
value: unknown;
onChange: (value: unknown) => void;
placeholder?: string;
helpText?: string;
required?: boolean;
readonly?: boolean;
error?: string;
selectOptions?: Array<{ label: string; value: string }>;
}
/**
* Maps cms_field_type to the appropriate Shadcn UI component.
* Replaces legacy PHP field rendering from my_modulklasse.
*/
export function FieldRenderer({
name,
displayName,
fieldType,
value,
onChange,
placeholder,
helpText,
required,
readonly,
error,
selectOptions,
}: FieldRendererProps) {
const fieldValue = value != null ? String(value) : '';
const renderField = () => {
switch (fieldType) {
case 'text':
case 'phone':
case 'url':
case 'color':
return (
<Input
name={name}
type={fieldType === 'color' ? 'color' : fieldType === 'url' ? 'url' : fieldType === 'phone' ? 'tel' : 'text'}
value={fieldValue}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
readOnly={readonly}
required={required}
/>
);
case 'email':
return (
<Input
name={name}
type="email"
value={fieldValue}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? 'email@example.de'}
readOnly={readonly}
required={required}
/>
);
case 'password':
return (
<Input
name={name}
type="password"
value={fieldValue}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
readOnly={readonly}
required={required}
/>
);
case 'textarea':
return (
<Textarea
name={name}
value={fieldValue}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
readOnly={readonly}
required={required}
rows={4}
/>
);
case 'richtext':
// Phase 3 enhancement: TipTap editor
return (
<Textarea
name={name}
value={fieldValue}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? 'Formatierter Text...'}
readOnly={readonly}
rows={6}
/>
);
case 'integer':
return (
<Input
name={name}
type="number"
step="1"
value={fieldValue}
onChange={(e) => onChange(parseInt(e.target.value, 10) || '')}
placeholder={placeholder}
readOnly={readonly}
required={required}
/>
);
case 'decimal':
case 'currency':
return (
<Input
name={name}
type="number"
step="0.01"
value={fieldValue}
onChange={(e) => onChange(parseFloat(e.target.value) || '')}
placeholder={placeholder ?? (fieldType === 'currency' ? '0,00 €' : undefined)}
readOnly={readonly}
required={required}
/>
);
case 'date':
return (
<Input
name={name}
type="date"
value={fieldValue}
onChange={(e) => onChange(e.target.value)}
readOnly={readonly}
required={required}
/>
);
case 'time':
return (
<Input
name={name}
type="time"
value={fieldValue}
onChange={(e) => onChange(e.target.value)}
readOnly={readonly}
required={required}
/>
);
case 'checkbox':
return (
<div className="flex items-center space-x-2">
<Checkbox
id={name}
checked={Boolean(value)}
onCheckedChange={(checked) => onChange(checked)}
disabled={readonly}
/>
<Label htmlFor={name}>{displayName}</Label>
</div>
);
case 'select':
case 'radio':
return (
<select
name={name}
value={fieldValue}
onChange={(e) => onChange(e.target.value)}
disabled={readonly}
required={required}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background"
>
<option value="">{placeholder ?? 'Bitte wählen...'}</option>
{selectOptions?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
case 'iban':
return (
<Input
name={name}
type="text"
value={fieldValue}
onChange={(e) => onChange(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))}
placeholder={placeholder ?? 'DE89 3704 0044 0532 0130 00'}
readOnly={readonly}
required={required}
maxLength={34}
/>
);
case 'file':
return (
<Input
name={name}
type="file"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onChange(file);
}}
disabled={readonly}
required={required}
/>
);
case 'hidden':
return <input type="hidden" name={name} value={fieldValue} />;
case 'computed':
return (
<div className="rounded-md border bg-muted px-3 py-2 text-sm">
{fieldValue || '—'}
</div>
);
default:
return (
<Input
name={name}
type="text"
value={fieldValue}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
readOnly={readonly}
/>
);
}
};
// Checkbox renders its own label
if (fieldType === 'checkbox') {
return (
<div className="space-y-1">
{renderField()}
{helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
return (
<div className="space-y-1.5">
<Label htmlFor={name}>
{displayName}
{required && <span className="text-destructive ml-1">*</span>}
</Label>
{renderField()}
{helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,5 @@
export { FieldRenderer } from './field-renderer';
export { ModuleForm } from './module-form';
export { ModuleTable } from './module-table';
export { ModuleSearch } from './module-search';
export { ModuleToolbar } from './module-toolbar';

View File

@@ -0,0 +1,123 @@
'use client';
import { useState } from 'react';
import { FieldRenderer } from './field-renderer';
import type { CmsFieldType } from '../schema/module.schema';
interface FieldDefinition {
name: string;
display_name: string;
field_type: CmsFieldType;
is_required: boolean;
placeholder?: string | null;
help_text?: string | null;
is_readonly: boolean;
select_options?: Array<{ label: string; value: string }> | null;
section: string;
sort_order: number;
show_in_form: boolean;
width: string;
}
interface ModuleFormProps {
fields: FieldDefinition[];
initialData?: Record<string, unknown>;
onSubmit: (data: Record<string, unknown>) => Promise<void>;
isLoading?: boolean;
errors?: Array<{ field: string; message: string }>;
}
/**
* Dynamic form component driven by module field definitions.
* Replaces the legacy my_modulklasse form rendering.
*/
export function ModuleForm({
fields,
initialData = {},
onSubmit,
isLoading = false,
errors = [],
}: ModuleFormProps) {
const [formData, setFormData] = useState<Record<string, unknown>>(initialData);
const visibleFields = fields
.filter((f) => f.show_in_form)
.sort((a, b) => a.sort_order - b.sort_order);
// Group fields by section
const sections = new Map<string, FieldDefinition[]>();
for (const field of visibleFields) {
const section = field.section || 'default';
if (!sections.has(section)) {
sections.set(section, []);
}
sections.get(section)!.push(field);
}
const handleFieldChange = (fieldName: string, value: unknown) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(formData);
};
const getFieldError = (fieldName: string) => {
return errors.find((e) => e.field === fieldName)?.message;
};
const getWidthClass = (width: string) => {
switch (width) {
case 'half': return 'col-span-1';
case 'third': return 'col-span-1';
case 'quarter': return 'col-span-1';
default: return 'col-span-full';
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{Array.from(sections.entries()).map(([sectionName, sectionFields]) => (
<div key={sectionName} className="space-y-4">
{sectionName !== 'default' && (
<h3 className="text-lg font-semibold border-b pb-2">
{sectionName}
</h3>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{sectionFields.map((field) => (
<div key={field.name} className={getWidthClass(field.width)}>
<FieldRenderer
name={field.name}
displayName={field.display_name}
fieldType={field.field_type}
value={formData[field.name]}
onChange={(value) => handleFieldChange(field.name, value)}
placeholder={field.placeholder ?? undefined}
helpText={field.help_text ?? undefined}
required={field.is_required}
readonly={field.is_readonly}
error={getFieldError(field.name)}
selectOptions={field.select_options ?? undefined}
/>
</div>
))}
</div>
</div>
))}
<div className="flex justify-end gap-2 pt-4 border-t">
<button
type="submit"
disabled={isLoading}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isLoading ? 'Wird gespeichert...' : 'Speichern'}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,167 @@
'use client';
import { useState } from 'react';
import { Input } from '@kit/ui/input';
interface FilterValue {
field: string;
operator: string;
value: string;
}
interface FieldOption {
name: string;
display_name: string;
show_in_filter: boolean;
show_in_search: boolean;
}
interface ModuleSearchProps {
fields: FieldOption[];
onSearch: (search: string) => void;
onFilter: (filters: FilterValue[]) => void;
initialSearch?: string;
initialFilters?: FilterValue[];
}
const OPERATORS = [
{ value: 'eq', label: 'gleich' },
{ value: 'neq', label: 'ungleich' },
{ value: 'like', label: 'enthält' },
{ value: 'gt', label: 'größer als' },
{ value: 'gte', label: 'größer/gleich' },
{ value: 'lt', label: 'kleiner als' },
{ value: 'lte', label: 'kleiner/gleich' },
{ value: 'is_null', label: 'ist leer' },
{ value: 'not_null', label: 'ist nicht leer' },
];
export function ModuleSearch({
fields,
onSearch,
onFilter,
initialSearch = '',
initialFilters = [],
}: ModuleSearchProps) {
const [search, setSearch] = useState(initialSearch);
const [showAdvanced, setShowAdvanced] = useState(initialFilters.length > 0);
const [filters, setFilters] = useState<FilterValue[]>(initialFilters);
const filterableFields = fields.filter((f) => f.show_in_filter);
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSearch(search);
};
const addFilter = () => {
if (filterableFields.length === 0) return;
setFilters([...filters, { field: filterableFields[0]!.name, operator: 'eq', value: '' }]);
};
const removeFilter = (index: number) => {
const next = filters.filter((_, i) => i !== index);
setFilters(next);
onFilter(next);
};
const updateFilter = (index: number, key: keyof FilterValue, value: string) => {
const next = [...filters];
next[index] = { ...next[index]!, [key]: value };
setFilters(next);
};
const applyFilters = () => {
onFilter(filters.filter((f) => f.value || f.operator === 'is_null' || f.operator === 'not_null'));
};
return (
<div className="space-y-3">
<form onSubmit={handleSearchSubmit} className="flex gap-2">
<Input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Suchen..."
className="max-w-sm"
/>
<button
type="submit"
className="rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground hover:bg-primary/90"
>
Suchen
</button>
{filterableFields.length > 0 && (
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
>
Filter {showAdvanced ? '▲' : '▼'}
</button>
)}
</form>
{showAdvanced && (
<div className="rounded-md border p-4 space-y-3">
{filters.map((filter, i) => (
<div key={i} className="flex gap-2 items-center">
<select
value={filter.field}
onChange={(e) => updateFilter(i, 'field', e.target.value)}
className="rounded-md border px-2 py-1.5 text-sm bg-background"
>
{filterableFields.map((f) => (
<option key={f.name} value={f.name}>{f.display_name}</option>
))}
</select>
<select
value={filter.operator}
onChange={(e) => updateFilter(i, 'operator', e.target.value)}
className="rounded-md border px-2 py-1.5 text-sm bg-background"
>
{OPERATORS.map((op) => (
<option key={op.value} value={op.value}>{op.label}</option>
))}
</select>
{filter.operator !== 'is_null' && filter.operator !== 'not_null' && (
<Input
value={filter.value}
onChange={(e) => updateFilter(i, 'value', e.target.value)}
placeholder="Wert..."
className="max-w-xs"
/>
)}
<button
type="button"
onClick={() => removeFilter(i)}
className="text-destructive hover:text-destructive/80 text-sm"
>
</button>
</div>
))}
<div className="flex gap-2">
<button
type="button"
onClick={addFilter}
className="rounded-md border px-3 py-1.5 text-sm hover:bg-muted"
>
+ Filter hinzufügen
</button>
{filters.length > 0 && (
<button
type="button"
onClick={applyFilters}
className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
>
Filter anwenden
</button>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,193 @@
'use client';
import type { CmsFieldType } from '../schema/module.schema';
interface FieldDefinition {
name: string;
display_name: string;
field_type: CmsFieldType;
show_in_table: boolean;
is_sortable: boolean;
sort_order: number;
}
interface ModuleRecord {
id: string;
data: Record<string, unknown>;
status: string;
created_at: string;
updated_at: string;
}
interface Pagination {
page: number;
pageSize: number;
total: number;
totalPages: number;
}
interface ModuleTableProps {
fields: FieldDefinition[];
records: ModuleRecord[];
pagination: Pagination;
onPageChange: (page: number) => void;
onSort: (field: string, direction: 'asc' | 'desc') => void;
onRowClick?: (recordId: string) => void;
selectedIds?: Set<string>;
onSelectionChange?: (ids: Set<string>) => void;
currentSort?: { field: string; direction: 'asc' | 'desc' };
}
/**
* Dynamic data table driven by module field definitions.
* Replaces the legacy my_modulklasse table rendering.
* Uses native table for now; Phase 3 enhancement will use @kit/ui/data-table.
*/
export function ModuleTable({
fields,
records,
pagination,
onPageChange,
onSort,
onRowClick,
selectedIds = new Set(),
onSelectionChange,
currentSort,
}: ModuleTableProps) {
const visibleFields = fields
.filter((f) => f.show_in_table)
.sort((a, b) => a.sort_order - b.sort_order);
const formatCellValue = (value: unknown, fieldType: CmsFieldType): string => {
if (value == null || value === '') return '—';
switch (fieldType) {
case 'checkbox':
return value ? '✓' : '✗';
case 'currency':
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(Number(value));
case 'date':
try { return new Date(String(value)).toLocaleDateString('de-DE'); } catch { return String(value); }
case 'password':
return '••••••';
default:
return String(value);
}
};
const handleSort = (fieldName: string) => {
const newDirection = currentSort?.field === fieldName && currentSort.direction === 'asc'
? 'desc' : 'asc';
onSort(fieldName, newDirection);
};
return (
<div className="space-y-4">
<div className="rounded-md border overflow-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
{onSelectionChange && (
<th className="p-3 w-10">
<input
type="checkbox"
checked={records.length > 0 && records.every((r) => selectedIds.has(r.id))}
onChange={(e) => {
if (e.target.checked) {
onSelectionChange(new Set(records.map((r) => r.id)));
} else {
onSelectionChange(new Set());
}
}}
/>
</th>
)}
{visibleFields.map((field) => (
<th
key={field.name}
className="p-3 text-left font-medium cursor-pointer hover:bg-muted/80 select-none"
onClick={() => field.is_sortable && handleSort(field.name)}
>
<span className="flex items-center gap-1">
{field.display_name}
{currentSort?.field === field.name && (
<span className="text-xs">{currentSort.direction === 'asc' ? '↑' : '↓'}</span>
)}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{records.length === 0 ? (
<tr>
<td
colSpan={visibleFields.length + (onSelectionChange ? 1 : 0)}
className="p-8 text-center text-muted-foreground"
>
Keine Datensätze gefunden
</td>
</tr>
) : (
records.map((record) => (
<tr
key={record.id}
className="border-b hover:bg-muted/30 cursor-pointer transition-colors"
onClick={() => onRowClick?.(record.id)}
>
{onSelectionChange && (
<td className="p-3" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(record.id)}
onChange={(e) => {
const next = new Set(selectedIds);
if (e.target.checked) {
next.add(record.id);
} else {
next.delete(record.id);
}
onSelectionChange(next);
}}
/>
</td>
)}
{visibleFields.map((field) => (
<td key={field.name} className="p-3">
{formatCellValue(record.data[field.name], field.field_type)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between px-2">
<span className="text-sm text-muted-foreground">
{pagination.total} Datensätze Seite {pagination.page} von {pagination.totalPages}
</span>
<div className="flex gap-1">
<button
onClick={() => onPageChange(pagination.page - 1)}
disabled={pagination.page <= 1}
className="rounded border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50"
>
Zurück
</button>
<button
onClick={() => onPageChange(pagination.page + 1)}
disabled={pagination.page >= pagination.totalPages}
className="rounded border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50"
>
Weiter
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,104 @@
'use client';
interface ModuleToolbarProps {
moduleName: string;
permissions: {
canInsert: boolean;
canDelete: boolean;
canImport: boolean;
canExport: boolean;
canPrint: boolean;
canLock: boolean;
canBulkEdit: boolean;
};
features: {
enableImport: boolean;
enableExport: boolean;
enablePrint: boolean;
enableLock: boolean;
enableBulkEdit: boolean;
};
selectedCount: number;
onNew?: () => void;
onDelete?: () => void;
onImport?: () => void;
onExport?: () => void;
onPrint?: () => void;
onLock?: () => void;
onBulkEdit?: () => void;
}
/**
* Permission-gated toolbar for module operations.
*/
export function ModuleToolbar({
permissions,
features,
selectedCount,
onNew,
onDelete,
onImport,
onExport,
onPrint,
onLock,
}: ModuleToolbarProps) {
return (
<div className="flex flex-wrap gap-2">
{permissions.canInsert && (
<button
onClick={onNew}
className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
+ Neu
</button>
)}
{selectedCount > 0 && permissions.canDelete && (
<button
onClick={onDelete}
className="rounded-md border border-destructive px-3 py-2 text-sm text-destructive hover:bg-destructive/10"
>
Löschen ({selectedCount})
</button>
)}
{selectedCount > 0 && permissions.canLock && features.enableLock && (
<button
onClick={onLock}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
>
Sperren ({selectedCount})
</button>
)}
<div className="flex-1" />
{permissions.canImport && features.enableImport && (
<button
onClick={onImport}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
>
Import
</button>
)}
{permissions.canExport && features.enableExport && (
<button
onClick={onExport}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
>
Export
</button>
)}
{permissions.canPrint && features.enablePrint && (
<button
onClick={onPrint}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
>
Drucken
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { z } from 'zod';
import { CmsFieldTypeEnum } from './module.schema';
/**
* Schema for creating / updating a module field.
*/
export const CreateFieldSchema = z.object({
moduleId: z.string().uuid(),
name: z.string().min(1).max(64).regex(/^[a-z][a-z0-9_]*$/, {
message: 'Field name must start with a letter and contain only lowercase letters, numbers, and underscores',
}),
displayName: z.string().min(1).max(128),
fieldType: CmsFieldTypeEnum,
sqlType: z.string().max(64).default('text'),
// Constraints
isRequired: z.boolean().default(false),
isUnique: z.boolean().default(false),
defaultValue: z.string().optional(),
minValue: z.number().optional(),
maxValue: z.number().optional(),
maxLength: z.number().int().optional(),
regexPattern: z.string().optional(),
// Layout
sortOrder: z.number().int().default(0),
width: z.enum(['full', 'half', 'third', 'quarter']).default('full'),
section: z.string().default('default'),
rowIndex: z.number().int().default(0),
colIndex: z.number().int().default(0),
placeholder: z.string().optional(),
helpText: z.string().optional(),
// Visibility
showInTable: z.boolean().default(true),
showInForm: z.boolean().default(true),
showInSearch: z.boolean().default(false),
showInFilter: z.boolean().default(false),
showInExport: z.boolean().default(true),
showInPrint: z.boolean().default(true),
// Behavior
isSortable: z.boolean().default(true),
isReadonly: z.boolean().default(false),
isEncrypted: z.boolean().default(false),
isCopyable: z.boolean().default(true),
validationFn: z.string().optional(),
// Lookup
lookupModuleId: z.string().uuid().optional(),
lookupDisplayField: z.string().optional(),
lookupValueField: z.string().optional(),
// Select options
selectOptions: z.array(z.object({
label: z.string(),
value: z.string(),
})).optional(),
// GDPR
isPersonalData: z.boolean().default(false),
gdprPurpose: z.string().optional(),
// File
allowedMimeTypes: z.array(z.string()).optional(),
maxFileSize: z.number().int().optional(),
});
export type CreateFieldInput = z.infer<typeof CreateFieldSchema>;
export const UpdateFieldSchema = CreateFieldSchema.partial().extend({
fieldId: z.string().uuid(),
});
export type UpdateFieldInput = z.infer<typeof UpdateFieldSchema>;

View File

@@ -0,0 +1,23 @@
import { z } from 'zod';
/**
* Schema for CSV/Excel import configuration.
*/
export const ColumnMappingSchema = z.object({
sourceColumn: z.string(),
targetField: z.string(),
transform: z.enum(['none', 'trim', 'lowercase', 'uppercase', 'date_dmy', 'date_mdy']).default('none'),
});
export type ColumnMapping = z.infer<typeof ColumnMappingSchema>;
export const ImportConfigSchema = z.object({
moduleId: z.string().uuid(),
accountId: z.string().uuid(),
mappings: z.array(ColumnMappingSchema).min(1),
skipFirstRow: z.boolean().default(true),
onDuplicate: z.enum(['skip', 'overwrite', 'error']).default('skip'),
dryRun: z.boolean().default(true),
});
export type ImportConfigInput = z.infer<typeof ImportConfigSchema>;

View File

@@ -0,0 +1,33 @@
import { z } from 'zod';
/**
* Schema for querying module records via the module_query() RPC.
*/
export const FilterSchema = z.object({
field: z.string().min(1),
operator: z.enum(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'is_null', 'not_null']),
value: z.string().optional(),
});
export type FilterInput = z.infer<typeof FilterSchema>;
export const ModuleQuerySchema = z.object({
moduleId: z.string().uuid(),
filters: z.array(FilterSchema).default([]),
sortField: z.string().optional(),
sortDirection: z.enum(['asc', 'desc']).default('asc'),
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(5).max(200).default(25),
search: z.string().optional(),
});
export type ModuleQueryInput = z.infer<typeof ModuleQuerySchema>;
export const PaginationSchema = z.object({
page: z.number(),
pageSize: z.number(),
total: z.number(),
totalPages: z.number(),
});
export type PaginationResult = z.infer<typeof PaginationSchema>;

View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
/**
* Schema for creating / updating a module record.
* The `data` field is a flexible JSONB object validated at runtime
* against the module's field definitions.
*/
export const CreateRecordSchema = z.object({
moduleId: z.string().uuid(),
accountId: z.string().uuid(),
data: z.record(z.string(), z.unknown()),
});
export type CreateRecordInput = z.infer<typeof CreateRecordSchema>;
export const UpdateRecordSchema = z.object({
recordId: z.string().uuid(),
data: z.record(z.string(), z.unknown()),
});
export type UpdateRecordInput = z.infer<typeof UpdateRecordSchema>;
export const DeleteRecordSchema = z.object({
recordId: z.string().uuid(),
hard: z.boolean().default(false),
});
export type DeleteRecordInput = z.infer<typeof DeleteRecordSchema>;
export const LockRecordSchema = z.object({
recordId: z.string().uuid(),
lock: z.boolean(),
});
export type LockRecordInput = z.infer<typeof LockRecordSchema>;

View File

@@ -0,0 +1,56 @@
import { z } from 'zod';
/**
* All CMS field types supported by the module engine.
* Maps 1:1 to the cms_field_type Postgres enum.
*/
export const CmsFieldTypeEnum = z.enum([
'text', 'textarea', 'richtext', 'checkbox', 'radio', 'hidden',
'select', 'password', 'file', 'date', 'time', 'decimal',
'integer', 'email', 'phone', 'url', 'currency', 'iban',
'color', 'computed',
]);
export type CmsFieldType = z.infer<typeof CmsFieldTypeEnum>;
export const CmsModuleStatusEnum = z.enum(['active', 'inactive', 'archived']);
export type CmsModuleStatus = z.infer<typeof CmsModuleStatusEnum>;
export const CmsRecordStatusEnum = z.enum(['active', 'locked', 'deleted', 'archived']);
export type CmsRecordStatus = z.infer<typeof CmsRecordStatusEnum>;
/**
* Schema for creating / updating a module definition.
*/
export const CreateModuleSchema = z.object({
accountId: z.string().uuid(),
name: z.string().min(1).max(64).regex(/^[a-z][a-z0-9_]*$/, {
message: 'Module name must start with a letter and contain only lowercase letters, numbers, and underscores',
}),
displayName: z.string().min(1).max(128),
description: z.string().max(1024).optional(),
icon: z.string().max(64).default('table'),
status: CmsModuleStatusEnum.default('active'),
sortOrder: z.number().int().default(0),
defaultSortField: z.string().optional(),
defaultSortDirection: z.enum(['asc', 'desc']).default('asc'),
defaultPageSize: z.number().int().min(5).max(200).default(25),
enableSearch: z.boolean().default(true),
enableFilter: z.boolean().default(true),
enableExport: z.boolean().default(true),
enableImport: z.boolean().default(false),
enablePrint: z.boolean().default(true),
enableCopy: z.boolean().default(false),
enableBulkEdit: z.boolean().default(false),
enableHistory: z.boolean().default(true),
enableSoftDelete: z.boolean().default(true),
enableLock: z.boolean().default(false),
});
export type CreateModuleInput = z.infer<typeof CreateModuleSchema>;
export const UpdateModuleSchema = CreateModuleSchema.partial().extend({
moduleId: z.string().uuid(),
});
export type UpdateModuleInput = z.infer<typeof UpdateModuleSchema>;

View File

@@ -0,0 +1,56 @@
'use server';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateModuleSchema, UpdateModuleSchema } from '../../schema/module.schema';
import { createModuleBuilderApi } from '../api';
export const createModule = authActionClient
.inputSchema(CreateModuleSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createModuleBuilderApi(client);
logger.info({ name: 'modules.create', moduleName: input.name }, 'Creating module...');
const module = await api.modules.createModule(input);
logger.info({ name: 'modules.create', moduleId: module.id }, 'Module created');
return { success: true, module };
});
export const updateModule = authActionClient
.inputSchema(UpdateModuleSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createModuleBuilderApi(client);
logger.info({ name: 'modules.update', moduleId: input.moduleId }, 'Updating module...');
const module = await api.modules.updateModule(input);
logger.info({ name: 'modules.update', moduleId: module.id }, 'Module updated');
return { success: true, module };
});
export const deleteModule = authActionClient
.inputSchema(UpdateModuleSchema.pick({ moduleId: true }))
.action(async ({ parsedInput: { moduleId } }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createModuleBuilderApi(client);
logger.info({ name: 'modules.delete', moduleId }, 'Archiving module...');
await api.modules.deleteModule(moduleId);
logger.info({ name: 'modules.delete', moduleId }, 'Module archived');
return { success: true };
});

View File

@@ -0,0 +1,132 @@
'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 { CreateRecordSchema, UpdateRecordSchema, DeleteRecordSchema, LockRecordSchema } from '../../schema/module-record.schema';
import { createModuleBuilderApi } from '../api';
import { validateRecordData } from '../services/record-validation.service';
export const createRecord = authActionClient
.inputSchema(CreateRecordSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createModuleBuilderApi(client);
const userId = ctx.user.id;
// Get field definitions for validation
const moduleWithFields = await api.modules.getModuleWithFields(input.moduleId);
if (!moduleWithFields) {
throw new Error('Module not found');
}
// Validate data against field definitions
const fields = (moduleWithFields as unknown as { fields: Array<{ name: string; field_type: string; is_required: boolean; min_value?: number | null; max_value?: number | null; max_length?: number | null; regex_pattern?: string | null }> }).fields;
const validation = validateRecordData(
input.data as Record<string, unknown>,
fields as Parameters<typeof validateRecordData>[1],
);
if (!validation.success) {
return { success: false, errors: validation.errors };
}
logger.info({ name: 'records.create', moduleId: input.moduleId }, 'Creating record...');
const record = await api.records.createRecord(input, userId);
// Write audit log
await api.audit.log({
accountId: input.accountId,
userId,
tableName: 'module_records',
recordId: record.id,
action: 'insert',
newData: input.data as Record<string, unknown>,
});
return { success: true, record };
});
export const updateRecord = authActionClient
.inputSchema(UpdateRecordSchema.extend({ accountId: z.string().uuid() }))
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createModuleBuilderApi(client);
const userId = ctx.user.id;
// Get existing record for audit
const existing = await api.records.getRecord(input.recordId);
logger.info({ name: 'records.update', recordId: input.recordId }, 'Updating record...');
const record = await api.records.updateRecord(input, userId);
// Write audit log
await api.audit.log({
accountId: input.accountId,
userId,
tableName: 'module_records',
recordId: record.id,
action: 'update',
oldData: existing.data as Record<string, unknown>,
newData: input.data as Record<string, unknown>,
});
return { success: true, record };
});
export const deleteRecord = authActionClient
.inputSchema(DeleteRecordSchema.extend({ accountId: z.string().uuid() }))
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createModuleBuilderApi(client);
const userId = ctx.user.id;
// Get existing record for audit
const existing = await api.records.getRecord(input.recordId);
logger.info({ name: 'records.delete', recordId: input.recordId, hard: input.hard }, 'Deleting record...');
await api.records.deleteRecord(input);
// Write audit log
await api.audit.log({
accountId: input.accountId,
userId,
tableName: 'module_records',
recordId: input.recordId,
action: 'delete',
oldData: existing.data as Record<string, unknown>,
});
return { success: true };
});
export const lockRecord = authActionClient
.inputSchema(LockRecordSchema.extend({ accountId: z.string().uuid() }))
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const api = createModuleBuilderApi(client);
const userId = ctx.user.id;
const record = await api.records.lockRecord(input, userId);
await api.audit.log({
accountId: input.accountId,
userId,
tableName: 'module_records',
recordId: input.recordId,
action: 'lock',
newData: { locked: input.lock },
});
return { success: true, record };
});

View File

@@ -0,0 +1,24 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import { createModuleDefinitionService } from './services/module-definition.service';
import { createModuleQueryService } from './services/module-query.service';
import { createRecordCrudService } from './services/record-crud.service';
import { createAuditService } from './services/audit.service';
/**
* Factory for the Module Builder API.
* Usage: const api = createModuleBuilderApi(client);
*
* Note: Uses untyped SupabaseClient until typegen includes new CMS tables.
* After running `pnpm supabase:web:typegen`, change to SupabaseClient<Database>.
*/
export function createModuleBuilderApi(client: SupabaseClient<Database>) {
return {
modules: createModuleDefinitionService(client),
query: createModuleQueryService(client),
records: createRecordCrudService(client),
audit: createAuditService(client),
};
}

View File

@@ -0,0 +1,33 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
type Json = Database['public']['Tables']['audit_log']['Insert']['old_data'];
export function createAuditService(client: SupabaseClient<Database>) {
return {
async log(params: {
accountId: string;
userId: string;
tableName: string;
recordId: string;
action: 'insert' | 'update' | 'delete' | 'lock';
oldData?: Record<string, unknown> | null;
newData?: Record<string, unknown> | null;
}) {
const { error } = await client.from('audit_log').insert({
account_id: params.accountId,
user_id: params.userId,
table_name: params.tableName,
record_id: params.recordId,
action: params.action,
old_data: (params.oldData ?? null) as Json,
new_data: (params.newData ?? null) as Json,
});
if (error) {
console.error('[audit] Failed to write audit log:', error.message);
}
},
};
}

View File

@@ -0,0 +1,120 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { CreateModuleInput, UpdateModuleInput } from '../../schema/module.schema';
/**
* Service for managing module definitions (CRUD).
* Note: Uses untyped client for new CMS tables until typegen runs.
*/
export function createModuleDefinitionService(client: SupabaseClient<Database>) {
return {
async listModules(accountId: string) {
const { data, error } = await client
.from('modules')
.select('*')
.eq('account_id', accountId)
.neq('status', 'archived')
.order('sort_order');
if (error) throw error;
return data;
},
async getModule(moduleId: string) {
const { data, error } = await client
.from('modules')
.select('*')
.eq('id', moduleId)
.single();
if (error) throw error;
return data;
},
async getModuleWithFields(moduleId: string) {
const { data, error } = await client
.from('modules')
.select('*, fields:module_fields(*)')
.eq('id', moduleId)
.single();
if (error) throw error;
return data;
},
async createModule(input: CreateModuleInput) {
const { data, error } = await client
.from('modules')
.insert({
account_id: input.accountId,
name: input.name,
display_name: input.displayName,
description: input.description,
icon: input.icon,
status: input.status,
sort_order: input.sortOrder,
default_sort_field: input.defaultSortField,
default_sort_direction: input.defaultSortDirection,
default_page_size: input.defaultPageSize,
enable_search: input.enableSearch,
enable_filter: input.enableFilter,
enable_export: input.enableExport,
enable_import: input.enableImport,
enable_print: input.enablePrint,
enable_copy: input.enableCopy,
enable_bulk_edit: input.enableBulkEdit,
enable_history: input.enableHistory,
enable_soft_delete: input.enableSoftDelete,
enable_lock: input.enableLock,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateModule(input: UpdateModuleInput) {
const updateData: Record<string, unknown> = {};
if (input.displayName !== undefined) updateData.display_name = input.displayName;
if (input.description !== undefined) updateData.description = input.description;
if (input.icon !== undefined) updateData.icon = input.icon;
if (input.status !== undefined) updateData.status = input.status;
if (input.sortOrder !== undefined) updateData.sort_order = input.sortOrder;
if (input.defaultSortField !== undefined) updateData.default_sort_field = input.defaultSortField;
if (input.defaultSortDirection !== undefined) updateData.default_sort_direction = input.defaultSortDirection;
if (input.defaultPageSize !== undefined) updateData.default_page_size = input.defaultPageSize;
if (input.enableSearch !== undefined) updateData.enable_search = input.enableSearch;
if (input.enableFilter !== undefined) updateData.enable_filter = input.enableFilter;
if (input.enableExport !== undefined) updateData.enable_export = input.enableExport;
if (input.enableImport !== undefined) updateData.enable_import = input.enableImport;
if (input.enablePrint !== undefined) updateData.enable_print = input.enablePrint;
if (input.enableCopy !== undefined) updateData.enable_copy = input.enableCopy;
if (input.enableBulkEdit !== undefined) updateData.enable_bulk_edit = input.enableBulkEdit;
if (input.enableHistory !== undefined) updateData.enable_history = input.enableHistory;
if (input.enableSoftDelete !== undefined) updateData.enable_soft_delete = input.enableSoftDelete;
if (input.enableLock !== undefined) updateData.enable_lock = input.enableLock;
const { data, error } = await client
.from('modules')
.update(updateData)
.eq('id', input.moduleId)
.select()
.single();
if (error) throw error;
return data;
},
async deleteModule(moduleId: string) {
const { error } = await client
.from('modules')
.update({ status: 'archived' })
.eq('id', moduleId);
if (error) throw error;
},
};
}

View File

@@ -0,0 +1,39 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { ModuleQueryInput } from '../../schema/module-query.schema';
/**
* Service for querying module records via the module_query() RPC.
* Note: Uses untyped client for new CMS tables until typegen runs.
*/
export function createModuleQueryService(client: SupabaseClient<Database>) {
return {
async query(input: ModuleQueryInput) {
const { data, error } = await client.rpc('module_query', {
p_module_id: input.moduleId,
p_filters: JSON.stringify(input.filters),
p_sort_field: input.sortField ?? undefined,
p_sort_direction: input.sortDirection,
p_page: input.page,
p_page_size: input.pageSize,
p_search: input.search ?? undefined,
});
if (error) throw error;
// RPC returns jsonb — parse the result
const result = typeof data === 'string' ? JSON.parse(data) : data;
return {
data: result.data ?? [],
pagination: result.pagination ?? {
page: input.page,
pageSize: input.pageSize,
total: 0,
totalPages: 0,
},
};
},
};
}

View File

@@ -0,0 +1,105 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type { CreateRecordInput, UpdateRecordInput, DeleteRecordInput, LockRecordInput } from '../../schema/module-record.schema';
type Json = Database['public']['Tables']['module_records']['Insert']['data'];
export function createRecordCrudService(client: SupabaseClient<Database>) {
return {
async getRecord(recordId: string) {
const { data, error } = await client
.from('module_records')
.select('*')
.eq('id', recordId)
.single();
if (error) throw error;
return data;
},
async createRecord(input: CreateRecordInput, userId: string) {
const { data, error } = await client
.from('module_records')
.insert({
module_id: input.moduleId,
account_id: input.accountId,
data: input.data as unknown as Json,
status: 'active',
created_by: userId,
updated_by: userId,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateRecord(input: UpdateRecordInput, userId: string) {
const existing = await this.getRecord(input.recordId);
if (existing.status === 'locked') {
throw new Error('Record is locked and cannot be edited');
}
const { data, error } = await client
.from('module_records')
.update({
data: input.data as unknown as Json,
updated_by: userId,
})
.eq('id', input.recordId)
.select()
.single();
if (error) throw error;
return data;
},
async deleteRecord(input: DeleteRecordInput) {
if (input.hard) {
const { error } = await client
.from('module_records')
.delete()
.eq('id', input.recordId);
if (error) throw error;
} else {
const { error } = await client
.from('module_records')
.update({ status: 'deleted' })
.eq('id', input.recordId);
if (error) throw error;
}
},
async lockRecord(input: LockRecordInput, userId: string) {
if (input.lock) {
const { data, error } = await client
.from('module_records')
.update({
status: 'locked',
locked_by: userId,
locked_at: new Date().toISOString(),
})
.eq('id', input.recordId)
.select()
.single();
if (error) throw error;
return data;
} else {
const { data, error } = await client
.from('module_records')
.update({
status: 'active',
locked_by: null,
locked_at: null,
})
.eq('id', input.recordId)
.select()
.single();
if (error) throw error;
return data;
}
},
};
}

View File

@@ -0,0 +1,178 @@
import { z } from 'zod';
import type { CmsFieldType } from '../../schema/module.schema';
/**
* Maps CMS field types to Zod validators at runtime.
* Replaces legacy check_* PHP functions from my_modulklasse.
*
* check_email -> z.string().email()
* check_text -> z.string()
* check_integer -> z.coerce.number().int()
* check_date -> z.coerce.date()
* check_iban -> custom IBAN validator (modulo 97)
* etc.
*/
const IBAN_REGEX = /^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$/;
function validateIban(value: string): boolean {
const cleaned = value.replace(/\s/g, '').toUpperCase();
if (!IBAN_REGEX.test(cleaned)) return false;
// Move first 4 chars to end
const rearranged = cleaned.slice(4) + cleaned.slice(0, 4);
// Convert letters to numbers (A=10, B=11, ...)
let numStr = '';
for (const char of rearranged) {
const code = char.charCodeAt(0);
if (code >= 65 && code <= 90) {
numStr += (code - 55).toString();
} else {
numStr += char;
}
}
// Modulo 97 check
let remainder = 0;
for (const digit of numStr) {
remainder = (remainder * 10 + parseInt(digit, 10)) % 97;
}
return remainder === 1;
}
interface FieldDefinition {
field_type: CmsFieldType;
is_required: boolean;
min_value?: number | null;
max_value?: number | null;
max_length?: number | null;
regex_pattern?: string | null;
}
/**
* Build a Zod schema for a single field based on its definition.
*/
export function buildFieldValidator(field: FieldDefinition): z.ZodTypeAny {
let schema: z.ZodTypeAny;
switch (field.field_type) {
case 'text':
case 'textarea':
case 'richtext':
case 'password':
case 'hidden':
case 'color':
schema = z.string();
if (field.max_length) {
schema = (schema as z.ZodString).max(field.max_length);
}
if (field.regex_pattern) {
schema = (schema as z.ZodString).regex(new RegExp(field.regex_pattern));
}
break;
case 'email':
schema = z.string().email();
break;
case 'phone':
schema = z.string().regex(/^[+]?[\d\s\-()]+$/, 'Invalid phone number');
break;
case 'url':
schema = z.string().url();
break;
case 'integer':
schema = z.coerce.number().int();
if (field.min_value != null) schema = (schema as z.ZodNumber).min(field.min_value);
if (field.max_value != null) schema = (schema as z.ZodNumber).max(field.max_value);
break;
case 'decimal':
case 'currency':
schema = z.coerce.number();
if (field.min_value != null) schema = (schema as z.ZodNumber).min(field.min_value);
if (field.max_value != null) schema = (schema as z.ZodNumber).max(field.max_value);
break;
case 'date':
case 'time':
schema = z.string().min(1);
break;
case 'checkbox':
schema = z.coerce.boolean();
break;
case 'select':
case 'radio':
schema = z.string();
break;
case 'file':
schema = z.string(); // storage path
break;
case 'iban':
schema = z.string().refine(validateIban, { message: 'Invalid IBAN' });
break;
case 'computed':
schema = z.any(); // computed fields are not user-editable
break;
default:
schema = z.string();
}
// Make optional if not required
if (!field.is_required) {
schema = schema.optional().or(z.literal(''));
}
return schema;
}
/**
* Build a complete Zod object schema from an array of field definitions.
*/
export function buildRecordValidator(fields: FieldDefinition[]): z.ZodObject<Record<string, z.ZodTypeAny>> {
const shape: Record<string, z.ZodTypeAny> = {};
for (const field of fields) {
const fieldDef = field as FieldDefinition & { name: string };
if ('name' in field) {
shape[fieldDef.name] = buildFieldValidator(field);
}
}
return z.object(shape);
}
/**
* Validate record data against field definitions.
* Returns { success: true, data } or { success: false, errors }.
*/
export function validateRecordData(
data: Record<string, unknown>,
fields: (FieldDefinition & { name: string })[],
) {
const schema = buildRecordValidator(fields);
const result = schema.safeParse(data);
if (result.success) {
return { success: true as const, data: result.data };
}
return {
success: false as const,
errors: result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
};
}