Add account hierarchy framework with migrations, RLS policies, and UI components
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user