Initial state for GitNexus analysis
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
5
packages/features/module-builder/src/components/index.ts
Normal file
5
packages/features/module-builder/src/components/index.ts
Normal 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';
|
||||
123
packages/features/module-builder/src/components/module-form.tsx
Normal file
123
packages/features/module-builder/src/components/module-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
193
packages/features/module-builder/src/components/module-table.tsx
Normal file
193
packages/features/module-builder/src/components/module-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user