Add account hierarchy framework with migrations, RLS policies, and UI components

This commit is contained in:
T. Zehetbauer
2026-03-31 22:18:04 +02:00
parent 7e7da0b465
commit 59546ad6d2
262 changed files with 11671 additions and 3927 deletions

View File

@@ -1,11 +1,11 @@
'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 { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Textarea } from '@kit/ui/textarea';
import type { CmsFieldType } from '../schema/module.schema';
interface FieldRendererProps {
name: string;
@@ -49,7 +49,15 @@ export function FieldRenderer({
return (
<Input
name={name}
type={fieldType === 'color' ? 'color' : fieldType === 'url' ? 'url' : fieldType === 'phone' ? 'tel' : 'text'}
type={
fieldType === 'color'
? 'color'
: fieldType === 'url'
? 'url'
: fieldType === 'phone'
? 'tel'
: 'text'
}
value={fieldValue}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
@@ -133,7 +141,9 @@ export function FieldRenderer({
step="0.01"
value={fieldValue}
onChange={(e) => onChange(parseFloat(e.target.value) || '')}
placeholder={placeholder ?? (fieldType === 'currency' ? '0,00 €' : undefined)}
placeholder={
placeholder ?? (fieldType === 'currency' ? '0,00 €' : undefined)
}
readOnly={readonly}
required={required}
/>
@@ -185,7 +195,7 @@ export function FieldRenderer({
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"
className="border-input bg-background ring-offset-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value="">{placeholder ?? 'Bitte wählen...'}</option>
{selectOptions?.map((opt) => (
@@ -202,7 +212,9 @@ export function FieldRenderer({
name={name}
type="text"
value={fieldValue}
onChange={(e) => onChange(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))}
onChange={(e) =>
onChange(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))
}
placeholder={placeholder ?? 'DE89 3704 0044 0532 0130 00'}
readOnly={readonly}
required={required}
@@ -229,7 +241,7 @@ export function FieldRenderer({
case 'computed':
return (
<div className="rounded-md border bg-muted px-3 py-2 text-sm">
<div className="bg-muted rounded-md border px-3 py-2 text-sm">
{fieldValue || '—'}
</div>
);
@@ -253,8 +265,10 @@ export function FieldRenderer({
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>}
{helpText && (
<p className="text-muted-foreground text-xs">{helpText}</p>
)}
{error && <p className="text-destructive text-xs">{error}</p>}
</div>
);
}
@@ -266,8 +280,8 @@ export function FieldRenderer({
{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>}
{helpText && <p className="text-muted-foreground text-xs">{helpText}</p>}
{error && <p className="text-destructive text-xs">{error}</p>}
</div>
);
}

View File

@@ -2,9 +2,8 @@
import { useState } from 'react';
import { FieldRenderer } from './field-renderer';
import type { CmsFieldType } from '../schema/module.schema';
import { FieldRenderer } from './field-renderer';
interface FieldDefinition {
name: string;
@@ -40,7 +39,8 @@ export function ModuleForm({
isLoading = false,
errors = [],
}: ModuleFormProps) {
const [formData, setFormData] = useState<Record<string, unknown>>(initialData);
const [formData, setFormData] =
useState<Record<string, unknown>>(initialData);
const visibleFields = fields
.filter((f) => f.show_in_form)
@@ -71,10 +71,14 @@ export function ModuleForm({
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';
case 'half':
return 'col-span-1';
case 'third':
return 'col-span-1';
case 'quarter':
return 'col-span-1';
default:
return 'col-span-full';
}
};
@@ -83,11 +87,11 @@ export function ModuleForm({
{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">
<h3 className="border-b pb-2 text-lg font-semibold">
{sectionName}
</h3>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{sectionFields.map((field) => (
<div key={field.name} className={getWidthClass(field.width)}>
<FieldRenderer
@@ -109,11 +113,11 @@ export function ModuleForm({
</div>
))}
<div className="flex justify-end gap-2 pt-4 border-t">
<div className="flex justify-end gap-2 border-t pt-4">
<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"
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{isLoading ? 'Wird gespeichert...' : 'Speichern'}
</button>

View File

@@ -57,7 +57,10 @@ export function ModuleSearch({
const addFilter = () => {
if (filterableFields.length === 0) return;
setFilters([...filters, { field: filterableFields[0]!.name, operator: 'eq', value: '' }]);
setFilters([
...filters,
{ field: filterableFields[0]!.name, operator: 'eq', value: '' },
]);
};
const removeFilter = (index: number) => {
@@ -66,14 +69,22 @@ export function ModuleSearch({
onFilter(next);
};
const updateFilter = (index: number, key: keyof FilterValue, value: string) => {
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'));
onFilter(
filters.filter(
(f) => f.value || f.operator === 'is_null' || f.operator === 'not_null',
),
);
};
return (
@@ -88,7 +99,7 @@ export function ModuleSearch({
/>
<button
type="submit"
className="rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground hover:bg-primary/90"
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-3 py-2 text-sm"
>
Suchen
</button>
@@ -96,7 +107,7 @@ export function ModuleSearch({
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
className="hover:bg-muted rounded-md border px-3 py-2 text-sm"
>
Filter {showAdvanced ? '▲' : '▼'}
</button>
@@ -104,35 +115,40 @@ export function ModuleSearch({
</form>
{showAdvanced && (
<div className="rounded-md border p-4 space-y-3">
<div className="space-y-3 rounded-md border p-4">
{filters.map((filter, i) => (
<div key={i} className="flex gap-2 items-center">
<div key={i} className="flex items-center gap-2">
<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"
className="bg-background rounded-md border px-2 py-1.5 text-sm"
>
{filterableFields.map((f) => (
<option key={f.name} value={f.name}>{f.display_name}</option>
<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"
className="bg-background rounded-md border px-2 py-1.5 text-sm"
>
{OPERATORS.map((op) => (
<option key={op.value} value={op.value}>{op.label}</option>
<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"
/>
)}
{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)}
@@ -146,7 +162,7 @@ export function ModuleSearch({
<button
type="button"
onClick={addFilter}
className="rounded-md border px-3 py-1.5 text-sm hover:bg-muted"
className="hover:bg-muted rounded-md border px-3 py-1.5 text-sm"
>
+ Filter hinzufügen
</button>
@@ -154,7 +170,7 @@ export function ModuleSearch({
<button
type="button"
onClick={applyFilters}
className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-3 py-1.5 text-sm"
>
Filter anwenden
</button>

View File

@@ -1,5 +1,7 @@
'use client';
import { formatDate } from '@kit/shared/dates';
import type { CmsFieldType } from '../schema/module.schema';
interface FieldDefinition {
@@ -65,9 +67,12 @@ export function ModuleTable({
case 'checkbox':
return value ? '✓' : '✗';
case 'currency':
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(Number(value));
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); }
return formatDate(value as string | Date | null | undefined);
case 'password':
return '••••••';
default:
@@ -76,22 +81,27 @@ export function ModuleTable({
};
const handleSort = (fieldName: string) => {
const newDirection = currentSort?.field === fieldName && currentSort.direction === 'asc'
? 'desc' : 'asc';
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">
<div className="overflow-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
{onSelectionChange && (
<th className="p-3 w-10">
<th className="w-10 p-3">
<input
type="checkbox"
checked={records.length > 0 && records.every((r) => selectedIds.has(r.id))}
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)));
@@ -105,13 +115,15 @@ export function ModuleTable({
{visibleFields.map((field) => (
<th
key={field.name}
className="p-3 text-left font-medium cursor-pointer hover:bg-muted/80 select-none"
className="hover:bg-muted/80 cursor-pointer p-3 text-left font-medium 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 className="text-xs">
{currentSort.direction === 'asc' ? '↑' : '↓'}
</span>
)}
</span>
</th>
@@ -123,7 +135,7 @@ export function ModuleTable({
<tr>
<td
colSpan={visibleFields.length + (onSelectionChange ? 1 : 0)}
className="p-8 text-center text-muted-foreground"
className="text-muted-foreground p-8 text-center"
>
Keine Datensätze gefunden
</td>
@@ -132,7 +144,7 @@ export function ModuleTable({
records.map((record) => (
<tr
key={record.id}
className="border-b hover:bg-muted/30 cursor-pointer transition-colors"
className="hover:bg-muted/30 cursor-pointer border-b transition-colors"
onClick={() => onRowClick?.(record.id)}
>
{onSelectionChange && (
@@ -154,7 +166,10 @@ export function ModuleTable({
)}
{visibleFields.map((field) => (
<td key={field.name} className="p-3">
{formatCellValue(record.data[field.name], field.field_type)}
{formatCellValue(
record.data[field.name],
field.field_type,
)}
</td>
))}
</tr>
@@ -167,21 +182,22 @@ export function ModuleTable({
{/* 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 className="text-muted-foreground text-sm">
{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"
className="hover:bg-muted rounded border px-3 py-1 text-sm 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"
className="hover:bg-muted rounded border px-3 py-1 text-sm disabled:opacity-50"
>
Weiter
</button>

View File

@@ -47,7 +47,7 @@ export function ModuleToolbar({
{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"
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-3 py-2 text-sm font-medium"
>
+ Neu
</button>
@@ -56,7 +56,7 @@ export function ModuleToolbar({
{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"
className="border-destructive text-destructive hover:bg-destructive/10 rounded-md border px-3 py-2 text-sm"
>
Löschen ({selectedCount})
</button>
@@ -65,7 +65,7 @@ export function ModuleToolbar({
{selectedCount > 0 && permissions.canLock && features.enableLock && (
<button
onClick={onLock}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
className="hover:bg-muted rounded-md border px-3 py-2 text-sm"
>
Sperren ({selectedCount})
</button>
@@ -76,7 +76,7 @@ export function ModuleToolbar({
{permissions.canImport && features.enableImport && (
<button
onClick={onImport}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
className="hover:bg-muted rounded-md border px-3 py-2 text-sm"
>
Import
</button>
@@ -85,7 +85,7 @@ export function ModuleToolbar({
{permissions.canExport && features.enableExport && (
<button
onClick={onExport}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
className="hover:bg-muted rounded-md border px-3 py-2 text-sm"
>
Export
</button>
@@ -94,7 +94,7 @@ export function ModuleToolbar({
{permissions.canPrint && features.enablePrint && (
<button
onClick={onPrint}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
className="hover:bg-muted rounded-md border px-3 py-2 text-sm"
>
Drucken
</button>