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>
|
||||
|
||||
@@ -7,9 +7,14 @@ import { CmsFieldTypeEnum } from './module.schema';
|
||||
*/
|
||||
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',
|
||||
}),
|
||||
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'),
|
||||
@@ -53,10 +58,14 @@ export const CreateFieldSchema = z.object({
|
||||
lookupValueField: z.string().optional(),
|
||||
|
||||
// Select options
|
||||
selectOptions: z.array(z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
})).optional(),
|
||||
selectOptions: z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
|
||||
// GDPR
|
||||
isPersonalData: z.boolean().default(false),
|
||||
|
||||
@@ -6,7 +6,9 @@ import { z } from 'zod';
|
||||
export const ColumnMappingSchema = z.object({
|
||||
sourceColumn: z.string(),
|
||||
targetField: z.string(),
|
||||
transform: z.enum(['none', 'trim', 'lowercase', 'uppercase', 'date_dmy', 'date_mdy']).default('none'),
|
||||
transform: z
|
||||
.enum(['none', 'trim', 'lowercase', 'uppercase', 'date_dmy', 'date_mdy'])
|
||||
.default('none'),
|
||||
});
|
||||
|
||||
export type ColumnMapping = z.infer<typeof ColumnMappingSchema>;
|
||||
|
||||
@@ -5,7 +5,17 @@ import { z } from 'zod';
|
||||
*/
|
||||
export const FilterSchema = z.object({
|
||||
field: z.string().min(1),
|
||||
operator: z.enum(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'is_null', 'not_null']),
|
||||
operator: z.enum([
|
||||
'eq',
|
||||
'neq',
|
||||
'gt',
|
||||
'gte',
|
||||
'lt',
|
||||
'lte',
|
||||
'like',
|
||||
'is_null',
|
||||
'not_null',
|
||||
]),
|
||||
value: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -5,10 +5,26 @@ import { z } from 'zod';
|
||||
* 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',
|
||||
'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>;
|
||||
@@ -16,7 +32,12 @@ 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 const CmsRecordStatusEnum = z.enum([
|
||||
'active',
|
||||
'locked',
|
||||
'deleted',
|
||||
'archived',
|
||||
]);
|
||||
export type CmsRecordStatus = z.infer<typeof CmsRecordStatusEnum>;
|
||||
|
||||
/**
|
||||
@@ -24,9 +45,14 @@ export type CmsRecordStatus = z.infer<typeof CmsRecordStatusEnum>;
|
||||
*/
|
||||
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',
|
||||
}),
|
||||
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'),
|
||||
|
||||
@@ -4,7 +4,10 @@ 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 {
|
||||
CreateModuleSchema,
|
||||
UpdateModuleSchema,
|
||||
} from '../../schema/module.schema';
|
||||
import { createModuleBuilderApi } from '../api';
|
||||
|
||||
export const createModule = authActionClient
|
||||
@@ -14,11 +17,17 @@ export const createModule = authActionClient
|
||||
const logger = await getLogger();
|
||||
const api = createModuleBuilderApi(client);
|
||||
|
||||
logger.info({ name: 'modules.create', moduleName: input.name }, 'Creating module...');
|
||||
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');
|
||||
logger.info(
|
||||
{ name: 'modules.create', moduleId: module.id },
|
||||
'Module created',
|
||||
);
|
||||
|
||||
return { success: true, module };
|
||||
});
|
||||
@@ -30,11 +39,17 @@ export const updateModule = authActionClient
|
||||
const logger = await getLogger();
|
||||
const api = createModuleBuilderApi(client);
|
||||
|
||||
logger.info({ name: 'modules.update', moduleId: input.moduleId }, 'Updating module...');
|
||||
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');
|
||||
logger.info(
|
||||
{ name: 'modules.update', moduleId: module.id },
|
||||
'Module updated',
|
||||
);
|
||||
|
||||
return { success: true, module };
|
||||
});
|
||||
|
||||
@@ -6,7 +6,12 @@ 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 {
|
||||
CreateRecordSchema,
|
||||
UpdateRecordSchema,
|
||||
DeleteRecordSchema,
|
||||
LockRecordSchema,
|
||||
} from '../../schema/module-record.schema';
|
||||
import { createModuleBuilderApi } from '../api';
|
||||
import { validateRecordData } from '../services/record-validation.service';
|
||||
|
||||
@@ -19,14 +24,28 @@ export const createRecord = authActionClient
|
||||
const userId = ctx.user.id;
|
||||
|
||||
// Get field definitions for validation
|
||||
const moduleWithFields = await api.modules.getModuleWithFields(input.moduleId);
|
||||
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 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],
|
||||
@@ -36,7 +55,10 @@ export const createRecord = authActionClient
|
||||
return { success: false, errors: validation.errors };
|
||||
}
|
||||
|
||||
logger.info({ name: 'records.create', moduleId: input.moduleId }, 'Creating record...');
|
||||
logger.info(
|
||||
{ name: 'records.create', moduleId: input.moduleId },
|
||||
'Creating record...',
|
||||
);
|
||||
|
||||
const record = await api.records.createRecord(input, userId);
|
||||
|
||||
@@ -64,7 +86,10 @@ export const updateRecord = authActionClient
|
||||
// Get existing record for audit
|
||||
const existing = await api.records.getRecord(input.recordId);
|
||||
|
||||
logger.info({ name: 'records.update', recordId: input.recordId }, 'Updating record...');
|
||||
logger.info(
|
||||
{ name: 'records.update', recordId: input.recordId },
|
||||
'Updating record...',
|
||||
);
|
||||
|
||||
const record = await api.records.updateRecord(input, userId);
|
||||
|
||||
@@ -93,7 +118,10 @@ export const deleteRecord = authActionClient
|
||||
// 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...');
|
||||
logger.info(
|
||||
{ name: 'records.delete', recordId: input.recordId, hard: input.hard },
|
||||
'Deleting record...',
|
||||
);
|
||||
|
||||
await api.records.deleteRecord(input);
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import { createAuditService } from './services/audit.service';
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
type Json = Database['public']['Tables']['audit_log']['Insert']['old_data'];
|
||||
@@ -26,7 +27,11 @@ export function createAuditService(client: SupabaseClient<Database>) {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('[audit] Failed to write audit log:', error.message);
|
||||
const logger = await getLogger();
|
||||
logger.error(
|
||||
{ error, context: 'audit-log' },
|
||||
'[audit] Failed to write audit log',
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { CreateModuleInput, UpdateModuleInput } from '../../schema/module.schema';
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
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>) {
|
||||
export function createModuleDefinitionService(
|
||||
client: SupabaseClient<Database>,
|
||||
) {
|
||||
return {
|
||||
async listModules(accountId: string) {
|
||||
const { data, error } = await client
|
||||
@@ -78,24 +84,40 @@ export function createModuleDefinitionService(client: SupabaseClient<Database>)
|
||||
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.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;
|
||||
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')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import type { ModuleQueryInput } from '../../schema/module-query.schema';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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';
|
||||
|
||||
import type {
|
||||
CreateRecordInput,
|
||||
UpdateRecordInput,
|
||||
DeleteRecordInput,
|
||||
LockRecordInput,
|
||||
} from '../../schema/module-record.schema';
|
||||
|
||||
type Json = Database['public']['Tables']['module_records']['Insert']['data'];
|
||||
|
||||
|
||||
@@ -88,15 +88,19 @@ export function buildFieldValidator(field: FieldDefinition): z.ZodTypeAny {
|
||||
|
||||
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);
|
||||
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);
|
||||
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':
|
||||
@@ -140,7 +144,9 @@ export function buildFieldValidator(field: FieldDefinition): z.ZodTypeAny {
|
||||
/**
|
||||
* Build a complete Zod object schema from an array of field definitions.
|
||||
*/
|
||||
export function buildRecordValidator(fields: FieldDefinition[]): z.ZodObject<Record<string, z.ZodTypeAny>> {
|
||||
export function buildRecordValidator(
|
||||
fields: FieldDefinition[],
|
||||
): z.ZodObject<Record<string, z.ZodTypeAny>> {
|
||||
const shape: Record<string, z.ZodTypeAny> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
|
||||
Reference in New Issue
Block a user