feat: add debug mode checkbox, update transfer mode descriptions
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2cf296dee6
commit
b5a9ee2d4a
7 changed files with 581 additions and 203 deletions
|
|
@ -66,6 +66,7 @@ export interface TeamsbotConfig {
|
||||||
triggerIntervalSeconds: number;
|
triggerIntervalSeconds: number;
|
||||||
triggerCooldownSeconds: number;
|
triggerCooldownSeconds: number;
|
||||||
contextWindowSegments: number;
|
contextWindowSegments: number;
|
||||||
|
debugMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamsbotSessionStats {
|
export interface TeamsbotSessionStats {
|
||||||
|
|
@ -96,6 +97,7 @@ export interface ConfigUpdateRequest {
|
||||||
triggerIntervalSeconds?: number;
|
triggerIntervalSeconds?: number;
|
||||||
triggerCooldownSeconds?: number;
|
triggerCooldownSeconds?: number;
|
||||||
contextWindowSegments?: number;
|
contextWindowSegments?: number;
|
||||||
|
debugMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voice/Language Types (from Google TTS API)
|
// Voice/Language Types (from Google TTS API)
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ export interface AttributeDefinition {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
minRows?: number; // For textarea types
|
minRows?: number; // For textarea types
|
||||||
maxRows?: number; // For textarea types
|
maxRows?: number; // For textarea types
|
||||||
|
editableOnCreate?: boolean;
|
||||||
|
editableOnUpdate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttributeOption {
|
export interface AttributeOption {
|
||||||
|
|
@ -86,6 +88,17 @@ export interface FormGeneratorFormProps<T = any> {
|
||||||
instanceId?: string;
|
instanceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isFieldEditableInMode = (attr: AttributeDefinition, currentMode: string): boolean => {
|
||||||
|
if (currentMode === 'display') return false;
|
||||||
|
if (currentMode === 'create') {
|
||||||
|
return attr.editableOnCreate !== undefined ? attr.editableOnCreate : attr.editable !== false;
|
||||||
|
}
|
||||||
|
if (currentMode === 'edit') {
|
||||||
|
return attr.editableOnUpdate !== undefined ? attr.editableOnUpdate : attr.editable !== false;
|
||||||
|
}
|
||||||
|
return attr.editable !== false;
|
||||||
|
};
|
||||||
|
|
||||||
// FormGeneratorForm component - Backend-driven form generation
|
// FormGeneratorForm component - Backend-driven form generation
|
||||||
export function FormGeneratorForm<T extends Record<string, any>>({
|
export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
entityType,
|
entityType,
|
||||||
|
|
@ -193,7 +206,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
filtered = filtered.filter(attr => attr.visible !== false);
|
filtered = filtered.filter(attr => attr.visible !== false);
|
||||||
} else if (mode === 'create') {
|
} else if (mode === 'create') {
|
||||||
// In create mode, hide truly non-editable fields (user can't set them)
|
// In create mode, hide truly non-editable fields (user can't set them)
|
||||||
filtered = filtered.filter(attr => attr.visible !== false && attr.editable !== false);
|
filtered = filtered.filter(attr => attr.visible !== false && isFieldEditableInMode(attr, 'create'));
|
||||||
} else if (mode === 'display') {
|
} else if (mode === 'display') {
|
||||||
filtered = filtered.filter(attr => attr.visible !== false);
|
filtered = filtered.filter(attr => attr.visible !== false);
|
||||||
}
|
}
|
||||||
|
|
@ -630,7 +643,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
const renderMultilingualField = (attr: AttributeDefinition) => {
|
const renderMultilingualField = (attr: AttributeDefinition) => {
|
||||||
const value = formData[attr.name] || { en: '' };
|
const value = formData[attr.name] || { en: '' };
|
||||||
const hasError = errors[attr.name];
|
const hasError = errors[attr.name];
|
||||||
const isReadonly = mode === 'display' || attr.readonly || attr.editable === false;
|
const isReadonly = mode === 'display' || attr.readonly || !isFieldEditableInMode(attr, mode);
|
||||||
|
|
||||||
// Ensure value is a TextMultilingual object
|
// Ensure value is a TextMultilingual object
|
||||||
const multilingualValue = isTextMultilingual(value) ? value : { en: typeof value === 'string' ? value : '' };
|
const multilingualValue = isTextMultilingual(value) ? value : { en: typeof value === 'string' ? value : '' };
|
||||||
|
|
@ -697,7 +710,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
const renderField = (attr: AttributeDefinition) => {
|
const renderField = (attr: AttributeDefinition) => {
|
||||||
const value = formData[attr.name];
|
const value = formData[attr.name];
|
||||||
const hasError = errors[attr.name];
|
const hasError = errors[attr.name];
|
||||||
const isReadonly = mode === 'display' || attr.readonly || attr.editable === false;
|
const isReadonly = mode === 'display' || attr.readonly || !isFieldEditableInMode(attr, mode);
|
||||||
|
|
||||||
// Check if this is a multilingual field - by explicit type ONLY
|
// Check if this is a multilingual field - by explicit type ONLY
|
||||||
const shouldRenderAsMultilingual = isMultilingualType(attr.type as AttributeType) &&
|
const shouldRenderAsMultilingual = isMultilingualType(attr.type as AttributeType) &&
|
||||||
|
|
|
||||||
|
|
@ -918,6 +918,44 @@ tbody .actionsColumn {
|
||||||
@keyframes booleanPulse {
|
@keyframes booleanPulse {
|
||||||
0%, 100% { opacity: 0.4; }
|
0%, 100% { opacity: 0.4; }
|
||||||
50% { opacity: 1; }
|
50% { opacity: 1; }
|
||||||
|
|
||||||
|
/* Grouping */
|
||||||
|
.groupHeader {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupHeader:hover {
|
||||||
|
background-color: var(--bg-hover, #f1f5f9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupHeader .td {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupToggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted, #64748b);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupCount {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted, #64748b);
|
||||||
|
font-weight: 400;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupActions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,12 @@ export interface FormGeneratorTableProps<T = any> {
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
// API endpoint for CSV export (e.g. "/api/users/"). If provided, the CSV export button is shown.
|
// API endpoint for CSV export (e.g. "/api/users/"). If provided, the CSV export button is shown.
|
||||||
apiEndpoint?: string;
|
apiEndpoint?: string;
|
||||||
|
// Grouping configuration
|
||||||
|
groupBy?: string;
|
||||||
|
groupRenderer?: (groupKey: string, groupRows: T[], isExpanded: boolean) => React.ReactNode;
|
||||||
|
groupRowData?: (groupKey: string, groupRows: T[]) => Record<string, React.ReactNode>;
|
||||||
|
groupDefaultExpanded?: boolean;
|
||||||
|
groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormGeneratorTable<T extends Record<string, any>>({
|
export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
|
|
@ -188,7 +194,12 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
getRowDataAttributes,
|
getRowDataAttributes,
|
||||||
hookData,
|
hookData,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
apiEndpoint
|
apiEndpoint,
|
||||||
|
groupBy,
|
||||||
|
groupRenderer: _groupRenderer,
|
||||||
|
groupRowData,
|
||||||
|
groupDefaultExpanded = true,
|
||||||
|
groupActions
|
||||||
}: FormGeneratorTableProps<T>) {
|
}: FormGeneratorTableProps<T>) {
|
||||||
const { t, currentLanguage: contextLanguage } = useLanguage();
|
const { t, currentLanguage: contextLanguage } = useLanguage();
|
||||||
// Map frontend language codes (de/en/fr) to backend codes (ge/en/fr) for multilingual field resolution
|
// Map frontend language codes (de/en/fr) to backend codes (ge/en/fr) for multilingual field resolution
|
||||||
|
|
@ -241,6 +252,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
|
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
|
||||||
const filterDropdownRef = useRef<HTMLDivElement>(null);
|
const filterDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Grouping: Track expanded groups
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => new Set());
|
||||||
|
const [groupsInitialized, setGroupsInitialized] = useState(false);
|
||||||
|
|
||||||
// FK Resolution: Cache for resolved FK values (fkSource -> { id -> displayLabel })
|
// FK Resolution: Cache for resolved FK values (fkSource -> { id -> displayLabel })
|
||||||
const [fkCache, setFkCache] = useState<FkCacheType>({});
|
const [fkCache, setFkCache] = useState<FkCacheType>({});
|
||||||
const [fkLoading, setFkLoading] = useState<Record<string, boolean>>({});
|
const [fkLoading, setFkLoading] = useState<Record<string, boolean>>({});
|
||||||
|
|
@ -340,6 +355,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
// Track container width for actions column 20% threshold
|
// Track container width for actions column 20% threshold
|
||||||
const [containerWidth, setContainerWidth] = useState<number>(0);
|
const [containerWidth, setContainerWidth] = useState<number>(0);
|
||||||
|
|
||||||
|
// Whether the actions column should be shown (standard or custom actions present)
|
||||||
|
const hasActionColumn = actionButtons.length > 0 || customActions.length > 0;
|
||||||
|
|
||||||
// Calculate default actions column width and track container width
|
// Calculate default actions column width and track container width
|
||||||
// Minimum width always fits 4 icons (4 * 26px button + 3 * 2px gap + 8px padding = 122px)
|
// Minimum width always fits 4 icons (4 * 26px button + 3 * 2px gap + 8px padding = 122px)
|
||||||
const MIN_ACTIONS_WIDTH_FOR_4_ICONS = 122;
|
const MIN_ACTIONS_WIDTH_FOR_4_ICONS = 122;
|
||||||
|
|
@ -599,6 +617,52 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
// No client-side processing needed
|
// No client-side processing needed
|
||||||
const displayData = data;
|
const displayData = data;
|
||||||
|
|
||||||
|
// Grouping: Group data by groupBy field if specified
|
||||||
|
const groupedData = useMemo(() => {
|
||||||
|
if (!groupBy || !displayData || displayData.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = new Map<string, T[]>();
|
||||||
|
for (const row of displayData) {
|
||||||
|
const groupKey = String(row[groupBy] || '');
|
||||||
|
if (!groups.has(groupKey)) {
|
||||||
|
groups.set(groupKey, []);
|
||||||
|
}
|
||||||
|
groups.get(groupKey)!.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [groupBy, displayData]);
|
||||||
|
|
||||||
|
// Initialize expanded groups when data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (groupBy && groupedData && !groupsInitialized) {
|
||||||
|
if (groupDefaultExpanded) {
|
||||||
|
setExpandedGroups(new Set(groupedData.keys()));
|
||||||
|
}
|
||||||
|
setGroupsInitialized(true);
|
||||||
|
}
|
||||||
|
}, [groupBy, groupedData, groupDefaultExpanded, groupsInitialized]);
|
||||||
|
|
||||||
|
// Reset groups initialization when groupBy changes
|
||||||
|
useEffect(() => {
|
||||||
|
setGroupsInitialized(false);
|
||||||
|
}, [groupBy]);
|
||||||
|
|
||||||
|
// Toggle group expansion
|
||||||
|
const toggleGroup = useCallback((groupKey: string) => {
|
||||||
|
setExpandedGroups(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(groupKey)) {
|
||||||
|
newSet.delete(groupKey);
|
||||||
|
} else {
|
||||||
|
newSet.add(groupKey);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Get pagination info from backend
|
// Get pagination info from backend
|
||||||
const totalPages = useMemo(() => {
|
const totalPages = useMemo(() => {
|
||||||
// If pagination object exists, use totalPages from backend
|
// If pagination object exists, use totalPages from backend
|
||||||
|
|
@ -702,15 +766,73 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
return Object.keys(filters).filter(k => filters[k] !== undefined && filters[k] !== '').length;
|
return Object.keys(filters).filter(k => filters[k] !== undefined && filters[k] !== '').length;
|
||||||
}, [filters]);
|
}, [filters]);
|
||||||
|
|
||||||
|
// Track which filter columns show all values (expanded beyond initial 100)
|
||||||
|
const [expandedFilterColumns, setExpandedFilterColumns] = useState<Set<string>>(new Set());
|
||||||
|
// Async-loaded filter values per column (from backend via hookData.fetchFilterValues)
|
||||||
|
const [asyncFilterValues, setAsyncFilterValues] = useState<Record<string, string[]>>({});
|
||||||
|
const [filterValuesLoading, setFilterValuesLoading] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const _toggleFilterExpand = useCallback((columnKey: string) => {
|
||||||
|
setExpandedFilterColumns(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(columnKey)) {
|
||||||
|
next.delete(columnKey);
|
||||||
|
} else {
|
||||||
|
next.add(columnKey);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load filter values on-demand when a filter dropdown is opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openFilterColumn) return;
|
||||||
|
|
||||||
|
const column = detectedColumns.find(c => c.key === openFilterColumn);
|
||||||
|
|
||||||
|
// Skip if column has static filterOptions (enum) – those are used directly
|
||||||
|
if (column?.filterOptions && column.filterOptions.length > 0) return;
|
||||||
|
|
||||||
|
// Skip if already loaded or currently loading
|
||||||
|
if (asyncFilterValues[openFilterColumn] || filterValuesLoading[openFilterColumn]) return;
|
||||||
|
|
||||||
|
// If the hook provides fetchFilterValues, use it (backend distinct query)
|
||||||
|
if (hookData?.fetchFilterValues && typeof hookData.fetchFilterValues === 'function') {
|
||||||
|
setFilterValuesLoading(prev => ({ ...prev, [openFilterColumn]: true }));
|
||||||
|
hookData.fetchFilterValues(openFilterColumn).then((values: string[]) => {
|
||||||
|
setAsyncFilterValues(prev => ({ ...prev, [openFilterColumn]: values }));
|
||||||
|
}).catch(() => {
|
||||||
|
// On error, fall back to current page data (set empty to prevent re-fetch)
|
||||||
|
setAsyncFilterValues(prev => ({ ...prev, [openFilterColumn]: [] }));
|
||||||
|
}).finally(() => {
|
||||||
|
setFilterValuesLoading(prev => ({ ...prev, [openFilterColumn]: false }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [openFilterColumn, detectedColumns, asyncFilterValues, filterValuesLoading, hookData]);
|
||||||
|
|
||||||
// Get unique values for a column (for filter dropdown)
|
// Get unique values for a column (for filter dropdown)
|
||||||
|
// Priority: 1) column.filterOptions (static enum)
|
||||||
|
// 2) asyncFilterValues (loaded from backend)
|
||||||
|
// 3) data (current page - fallback)
|
||||||
const getUniqueValuesForColumn = useCallback((columnKey: string): string[] => {
|
const getUniqueValuesForColumn = useCallback((columnKey: string): string[] => {
|
||||||
|
const column = detectedColumns.find(c => c.key === columnKey);
|
||||||
|
|
||||||
|
// Static enum options defined in the column config
|
||||||
|
if (column?.filterOptions && column.filterOptions.length > 0) {
|
||||||
|
return column.filterOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Values loaded asynchronously from the backend (all data, not just page)
|
||||||
|
if (asyncFilterValues[columnKey] && asyncFilterValues[columnKey].length > 0) {
|
||||||
|
return asyncFilterValues[columnKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: extract from current page data
|
||||||
const values = new Set<string>();
|
const values = new Set<string>();
|
||||||
data.forEach(row => {
|
data.forEach(row => {
|
||||||
const value = row[columnKey];
|
const value = row[columnKey];
|
||||||
if (value !== undefined && value !== null && value !== '') {
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
// Handle different value types
|
|
||||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||||
// For objects (like TextMultilingual), get the string representation
|
|
||||||
if (isTextMultilingual(value)) {
|
if (isTextMultilingual(value)) {
|
||||||
const text = value.en || Object.values(value)[0];
|
const text = value.en || Object.values(value)[0];
|
||||||
if (text) values.add(String(text));
|
if (text) values.add(String(text));
|
||||||
|
|
@ -725,7 +847,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return Array.from(values).sort();
|
return Array.from(values).sort();
|
||||||
}, [data]);
|
}, [data, detectedColumns, asyncFilterValues]);
|
||||||
|
|
||||||
// Close filter dropdown when clicking outside
|
// Close filter dropdown when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1565,13 +1687,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state - only shown when not loading AND no data */}
|
<table ref={tableRef} className={styles.table}>
|
||||||
{!loading && displayData.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<p className={styles.emptyMessage}>{emptyMessage || t('formgen.empty', 'No data available')}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<table ref={tableRef} className={styles.table}>
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{selectable && (
|
{selectable && (
|
||||||
|
|
@ -1590,7 +1706,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
{actionButtons.length > 0 && (
|
{hasActionColumn && (
|
||||||
<th
|
<th
|
||||||
className={styles.actionsColumn}
|
className={styles.actionsColumn}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -1689,22 +1805,51 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
>
|
>
|
||||||
({t('formgen.filter.all', 'All')})
|
({t('formgen.filter.all', 'All')})
|
||||||
</div>
|
</div>
|
||||||
{/* Unique values from data */}
|
{/* Filter values - loaded from backend or static filterOptions */}
|
||||||
{getUniqueValuesForColumn(column.key).slice(0, 50).map(value => (
|
{filterValuesLoading[column.key] ? (
|
||||||
<div
|
<div className={styles.filterOptionMore} style={{ textAlign: 'center', padding: '8px' }}>
|
||||||
key={value}
|
{t('formgen.filter.loading', 'Lade Filterwerte...')}
|
||||||
className={`${styles.filterOption} ${filters[column.key] === value ? styles.filterOptionSelected : ''}`}
|
|
||||||
onClick={() => handleFilter(column.key, value)}
|
|
||||||
title={value}
|
|
||||||
>
|
|
||||||
{value.length > 30 ? value.substring(0, 30) + '...' : value}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : (() => {
|
||||||
{getUniqueValuesForColumn(column.key).length > 50 && (
|
const allValues = getUniqueValuesForColumn(column.key);
|
||||||
<div className={styles.filterOptionMore}>
|
const isExpanded = expandedFilterColumns.has(column.key);
|
||||||
... {t('formgen.filter.more', 'and {count} more').replace('{count}', String(getUniqueValuesForColumn(column.key).length - 50))}
|
const displayLimit = isExpanded ? allValues.length : 100;
|
||||||
</div>
|
const visibleValues = allValues.slice(0, displayLimit);
|
||||||
)}
|
const remaining = allValues.length - displayLimit;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visibleValues.map(value => (
|
||||||
|
<div
|
||||||
|
key={value}
|
||||||
|
className={`${styles.filterOption} ${filters[column.key] === value ? styles.filterOptionSelected : ''}`}
|
||||||
|
onClick={() => handleFilter(column.key, value)}
|
||||||
|
title={value}
|
||||||
|
>
|
||||||
|
{value.length > 30 ? value.substring(0, 30) + '...' : value}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{remaining > 0 && (
|
||||||
|
<div
|
||||||
|
className={styles.filterOptionMore}
|
||||||
|
onClick={() => _toggleFilterExpand(column.key)}
|
||||||
|
style={{ cursor: 'pointer', color: 'var(--primary-blue, #003d7a)' }}
|
||||||
|
>
|
||||||
|
+ {remaining} {t('formgen.filter.more', 'weitere anzeigen')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isExpanded && allValues.length > 100 && (
|
||||||
|
<div
|
||||||
|
className={styles.filterOptionMore}
|
||||||
|
onClick={() => _toggleFilterExpand(column.key)}
|
||||||
|
style={{ cursor: 'pointer', color: 'var(--primary-blue, #003d7a)' }}
|
||||||
|
>
|
||||||
|
{t('formgen.filter.less', 'Weniger anzeigen')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1719,177 +1864,313 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
{displayData.length > 0 && (
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{displayData.map((row, index) => {
|
{groupBy && groupedData ? (
|
||||||
const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {};
|
Array.from(groupedData.entries()).map(([groupKey, groupRows]) => {
|
||||||
return (
|
const isExpanded = expandedGroups.has(groupKey);
|
||||||
<tr
|
const cellData = groupRowData ? groupRowData(groupKey, groupRows) : null;
|
||||||
key={index}
|
|
||||||
className={`${styles.tr} ${selectedRows.has(index) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
|
||||||
onClick={() => onRowClick?.(row, index)}
|
|
||||||
{...Object.fromEntries(
|
|
||||||
Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value])
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{selectable && (
|
|
||||||
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedRows.has(index)}
|
|
||||||
onChange={() => handleRowSelect(index)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
disabled={isRowSelectable && !isRowSelectable(row)}
|
|
||||||
title={
|
|
||||||
isRowSelectable && !isRowSelectable(row)
|
|
||||||
? t('formgen.select.disabled', 'This item cannot be selected')
|
|
||||||
: t('formgen.select.item', 'Select this item')
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
opacity: isRowSelectable && !isRowSelectable(row) ? 0.4 : 1,
|
|
||||||
cursor: isRowSelectable && !isRowSelectable(row) ? 'not-allowed' : 'pointer'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
{actionButtons.length > 0 && (
|
|
||||||
<td
|
|
||||||
className={styles.actionsColumn}
|
|
||||||
style={{
|
|
||||||
width: `${currentActionsWidth}px`,
|
|
||||||
minWidth: `${defaultActionsWidth}px`
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={(el) => {
|
|
||||||
if (el) {
|
|
||||||
actionButtonsRefs.current.set(index, el);
|
|
||||||
} else {
|
|
||||||
actionButtonsRefs.current.delete(index);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}
|
|
||||||
>
|
|
||||||
{/* Standard action buttons (edit, delete, view, copy) */}
|
|
||||||
{actionButtons.map((actionButton, actionIndex) => {
|
|
||||||
const actionTitle = typeof actionButton.title === 'function'
|
|
||||||
? actionButton.title(row)
|
|
||||||
: actionButton.title;
|
|
||||||
|
|
||||||
// Row-level permission check - uses _permissions from backend API
|
|
||||||
// Backend delivers per-record permissions: { _permissions: { canEdit, canDelete } }
|
|
||||||
let disabledResult: boolean | { disabled: boolean; message?: string } = false;
|
|
||||||
if (actionButton.disabled) {
|
|
||||||
// Explicit disabled function takes precedence
|
|
||||||
disabledResult = actionButton.disabled(row, hookData);
|
|
||||||
} else if (row._permissions) {
|
|
||||||
// Use per-record permissions from backend
|
|
||||||
if (actionButton.type === 'edit' && row._permissions.canUpdate === false) {
|
|
||||||
disabledResult = true;
|
|
||||||
} else if (actionButton.type === 'delete' && row._permissions.canDelete === false) {
|
|
||||||
disabledResult = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLoading = actionButton.loading ? actionButton.loading(row) : false;
|
|
||||||
const isProcessing = actionButton.isProcessing ? actionButton.isProcessing(row) : false;
|
|
||||||
|
|
||||||
const baseProps = {
|
|
||||||
row,
|
|
||||||
disabled: disabledResult,
|
|
||||||
loading: isLoading,
|
|
||||||
className: actionButton.className,
|
|
||||||
title: actionTitle,
|
|
||||||
idField: actionButton.idField ?? 'id',
|
|
||||||
nameField: actionButton.nameField ?? 'name',
|
|
||||||
typeField: actionButton.typeField ?? 'type',
|
|
||||||
contentField: actionButton.contentField ?? 'content',
|
|
||||||
operationName: actionButton.operationName,
|
|
||||||
loadingStateName: actionButton.loadingStateName
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (actionButton.type) {
|
|
||||||
case 'edit':
|
|
||||||
return <EditActionButton
|
|
||||||
key={`action-${actionIndex}`}
|
|
||||||
{...baseProps}
|
|
||||||
onEdit={actionButton.onAction}
|
|
||||||
hookData={hookData}
|
|
||||||
/>;
|
|
||||||
case 'delete':
|
|
||||||
return <DeleteActionButton
|
|
||||||
key={`action-${actionIndex}`}
|
|
||||||
{...baseProps}
|
|
||||||
containerRef={{ current: actionButtonsRefs.current.get(index) || null }}
|
|
||||||
hookData={hookData}
|
|
||||||
/>;
|
|
||||||
case 'view':
|
|
||||||
return <ViewActionButton
|
|
||||||
key={`action-${actionIndex}`}
|
|
||||||
{...baseProps}
|
|
||||||
onView={actionButton.onAction || (() => {})}
|
|
||||||
isViewing={isProcessing}
|
|
||||||
hookData={hookData}
|
|
||||||
/>;
|
|
||||||
case 'copy':
|
|
||||||
return <CopyActionButton
|
|
||||||
key={`action-${actionIndex}`}
|
|
||||||
{...baseProps}
|
|
||||||
onCopy={actionButton.onAction}
|
|
||||||
isCopying={isProcessing}
|
|
||||||
contentField={actionButton.contentField}
|
|
||||||
/>;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
{/* Custom action buttons (entity-specific actions) */}
|
|
||||||
{customActions.map((customAction) => (
|
|
||||||
<CustomActionButton
|
|
||||||
key={`custom-${customAction.id}`}
|
|
||||||
row={row}
|
|
||||||
id={customAction.id}
|
|
||||||
icon={customAction.icon}
|
|
||||||
onClick={customAction.onClick}
|
|
||||||
visible={customAction.visible}
|
|
||||||
disabled={customAction.disabled}
|
|
||||||
loading={customAction.loading}
|
|
||||||
title={customAction.title}
|
|
||||||
className={customAction.className}
|
|
||||||
hookData={hookData}
|
|
||||||
idField={customAction.idField ?? 'id'}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
{detectedColumns.map(column => {
|
|
||||||
const cellValue = row[column.key];
|
|
||||||
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
|
|
||||||
const combinedClassName = `${styles.td} ${customClassName}`.trim();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<td
|
<React.Fragment key={`group-${groupKey}`}>
|
||||||
key={column.key}
|
<tr
|
||||||
className={combinedClassName}
|
className={`${styles.tr} ${styles.groupHeader}`}
|
||||||
style={{
|
onClick={() => toggleGroup(groupKey)}
|
||||||
width: columnWidths[column.key] || column.width || 150,
|
style={{
|
||||||
minWidth: columnWidths[column.key] || column.width || 150,
|
cursor: 'pointer',
|
||||||
maxWidth: columnWidths[column.key] || column.width || 150
|
backgroundColor: 'var(--bg-secondary, #f8fafc)',
|
||||||
}}
|
borderBottom: '2px solid var(--border-color, #e2e8f0)'
|
||||||
>
|
}}
|
||||||
{formatCellValue(cellValue, column, row)}
|
>
|
||||||
</td>
|
{selectable && (
|
||||||
|
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px', textAlign: 'center' }}>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', fontSize: '11px', transition: 'transform 0.2s',
|
||||||
|
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||||
|
color: 'var(--text-muted, #64748b)',
|
||||||
|
}}>▶</span>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{hasActionColumn && (
|
||||||
|
<td
|
||||||
|
className={styles.actionsColumn}
|
||||||
|
style={{ width: `${currentActionsWidth}px`, minWidth: `${defaultActionsWidth}px` }}
|
||||||
|
>
|
||||||
|
{!selectable && (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', fontSize: '11px', transition: 'transform 0.2s',
|
||||||
|
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||||
|
color: 'var(--text-muted, #64748b)', marginRight: '6px',
|
||||||
|
}}>▶</span>
|
||||||
|
)}
|
||||||
|
{groupActions && (
|
||||||
|
<span onClick={(e) => e.stopPropagation()}>
|
||||||
|
{groupActions(groupKey, groupRows)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{detectedColumns.map((column, colIdx) => {
|
||||||
|
const cellContent = cellData?.[column.key];
|
||||||
|
const hasContent = cellContent !== undefined && cellContent !== null;
|
||||||
|
const showExpandIcon = !selectable && actionButtons.length === 0 && colIdx === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={column.key}
|
||||||
|
className={styles.td}
|
||||||
|
style={{
|
||||||
|
width: columnWidths[column.key] || column.width || 150,
|
||||||
|
minWidth: columnWidths[column.key] || column.width || 150,
|
||||||
|
maxWidth: columnWidths[column.key] || column.width || 150,
|
||||||
|
fontWeight: hasContent ? 600 : 'normal',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showExpandIcon && (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', fontSize: '11px', transition: 'transform 0.2s',
|
||||||
|
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||||
|
color: 'var(--text-muted, #64748b)', marginRight: '6px',
|
||||||
|
}}>▶</span>
|
||||||
|
)}
|
||||||
|
{hasContent ? cellContent : ''}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
{isExpanded && groupRows.map((row, rowIndex) => {
|
||||||
|
const globalIndex = displayData.indexOf(row);
|
||||||
|
const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, globalIndex) : {};
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`${groupKey}-row-${rowIndex}`}
|
||||||
|
className={`${styles.tr} ${selectedRows.has(globalIndex) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
||||||
|
onClick={() => onRowClick?.(row, globalIndex)}
|
||||||
|
{...Object.fromEntries(
|
||||||
|
Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value])
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectable && (
|
||||||
|
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedRows.has(globalIndex)}
|
||||||
|
onChange={() => handleRowSelect(globalIndex)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
disabled={isRowSelectable && !isRowSelectable(row)}
|
||||||
|
title={
|
||||||
|
isRowSelectable && !isRowSelectable(row)
|
||||||
|
? t('formgen.select.disabled', 'This item cannot be selected')
|
||||||
|
: t('formgen.select.item', 'Select this item')
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
opacity: isRowSelectable && !isRowSelectable(row) ? 0.4 : 1,
|
||||||
|
cursor: isRowSelectable && !isRowSelectable(row) ? 'not-allowed' : 'pointer'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{hasActionColumn && (
|
||||||
|
<td
|
||||||
|
className={styles.actionsColumn}
|
||||||
|
style={{
|
||||||
|
width: `${currentActionsWidth}px`,
|
||||||
|
minWidth: `${defaultActionsWidth}px`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) {
|
||||||
|
actionButtonsRefs.current.set(globalIndex, el);
|
||||||
|
} else {
|
||||||
|
actionButtonsRefs.current.delete(globalIndex);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}
|
||||||
|
>
|
||||||
|
{actionButtons.map((actionButton, actionIndex) => {
|
||||||
|
const actionTitle = typeof actionButton.title === 'function'
|
||||||
|
? actionButton.title(row)
|
||||||
|
: actionButton.title;
|
||||||
|
let disabledResult: boolean | { disabled: boolean; message?: string } = false;
|
||||||
|
if (actionButton.disabled) {
|
||||||
|
disabledResult = actionButton.disabled(row, hookData);
|
||||||
|
} else if (row._permissions) {
|
||||||
|
if (actionButton.type === 'edit' && row._permissions.canUpdate === false) {
|
||||||
|
disabledResult = true;
|
||||||
|
} else if (actionButton.type === 'delete' && row._permissions.canDelete === false) {
|
||||||
|
disabledResult = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isLoading = actionButton.loading ? actionButton.loading(row) : false;
|
||||||
|
const isProcessing = actionButton.isProcessing ? actionButton.isProcessing(row) : false;
|
||||||
|
const baseProps = {
|
||||||
|
row, disabled: disabledResult, loading: isLoading,
|
||||||
|
className: actionButton.className, title: actionTitle,
|
||||||
|
idField: actionButton.idField ?? 'id', nameField: actionButton.nameField ?? 'name',
|
||||||
|
typeField: actionButton.typeField ?? 'type', contentField: actionButton.contentField ?? 'content',
|
||||||
|
operationName: actionButton.operationName, loadingStateName: actionButton.loadingStateName
|
||||||
|
};
|
||||||
|
switch (actionButton.type) {
|
||||||
|
case 'edit':
|
||||||
|
return <EditActionButton key={`action-${actionIndex}`} {...baseProps} onEdit={actionButton.onAction} hookData={hookData} />;
|
||||||
|
case 'delete':
|
||||||
|
return <DeleteActionButton key={`action-${actionIndex}`} {...baseProps} containerRef={{ current: actionButtonsRefs.current.get(globalIndex) || null }} hookData={hookData} />;
|
||||||
|
case 'view':
|
||||||
|
return <ViewActionButton key={`action-${actionIndex}`} {...baseProps} onView={actionButton.onAction || (() => {})} isViewing={isProcessing} hookData={hookData} />;
|
||||||
|
case 'copy':
|
||||||
|
return <CopyActionButton key={`action-${actionIndex}`} {...baseProps} onCopy={actionButton.onAction} isCopying={isProcessing} contentField={actionButton.contentField} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
{customActions.map((customAction) => (
|
||||||
|
<CustomActionButton key={`custom-${customAction.id}`} row={row} id={customAction.id} icon={customAction.icon}
|
||||||
|
onClick={customAction.onClick} visible={customAction.visible} disabled={customAction.disabled}
|
||||||
|
loading={customAction.loading} title={customAction.title} className={customAction.className}
|
||||||
|
hookData={hookData} idField={customAction.idField ?? 'id'} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{detectedColumns.map(column => {
|
||||||
|
const cellValue = row[column.key];
|
||||||
|
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
|
||||||
|
const combinedClassName = `${styles.td} ${customClassName}`.trim();
|
||||||
|
return (
|
||||||
|
<td key={column.key} className={combinedClassName}
|
||||||
|
style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150 }}>
|
||||||
|
{formatCellValue(cellValue, column, row)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
) : (
|
||||||
</tr>
|
displayData.map((row, index) => {
|
||||||
);
|
const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {};
|
||||||
})}
|
return (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
className={`${styles.tr} ${selectedRows.has(index) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
||||||
|
onClick={() => onRowClick?.(row, index)}
|
||||||
|
{...Object.fromEntries(
|
||||||
|
Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value])
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectable && (
|
||||||
|
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedRows.has(index)}
|
||||||
|
onChange={() => handleRowSelect(index)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
disabled={isRowSelectable && !isRowSelectable(row)}
|
||||||
|
title={
|
||||||
|
isRowSelectable && !isRowSelectable(row)
|
||||||
|
? t('formgen.select.disabled', 'This item cannot be selected')
|
||||||
|
: t('formgen.select.item', 'Select this item')
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
opacity: isRowSelectable && !isRowSelectable(row) ? 0.4 : 1,
|
||||||
|
cursor: isRowSelectable && !isRowSelectable(row) ? 'not-allowed' : 'pointer'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{hasActionColumn && (
|
||||||
|
<td
|
||||||
|
className={styles.actionsColumn}
|
||||||
|
style={{
|
||||||
|
width: `${currentActionsWidth}px`,
|
||||||
|
minWidth: `${defaultActionsWidth}px`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) {
|
||||||
|
actionButtonsRefs.current.set(index, el);
|
||||||
|
} else {
|
||||||
|
actionButtonsRefs.current.delete(index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}
|
||||||
|
>
|
||||||
|
{actionButtons.map((actionButton, actionIndex) => {
|
||||||
|
const actionTitle = typeof actionButton.title === 'function'
|
||||||
|
? actionButton.title(row)
|
||||||
|
: actionButton.title;
|
||||||
|
let disabledResult: boolean | { disabled: boolean; message?: string } = false;
|
||||||
|
if (actionButton.disabled) {
|
||||||
|
disabledResult = actionButton.disabled(row, hookData);
|
||||||
|
} else if (row._permissions) {
|
||||||
|
if (actionButton.type === 'edit' && row._permissions.canUpdate === false) {
|
||||||
|
disabledResult = true;
|
||||||
|
} else if (actionButton.type === 'delete' && row._permissions.canDelete === false) {
|
||||||
|
disabledResult = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isLoading = actionButton.loading ? actionButton.loading(row) : false;
|
||||||
|
const isProcessing = actionButton.isProcessing ? actionButton.isProcessing(row) : false;
|
||||||
|
const baseProps = {
|
||||||
|
row, disabled: disabledResult, loading: isLoading,
|
||||||
|
className: actionButton.className, title: actionTitle,
|
||||||
|
idField: actionButton.idField ?? 'id', nameField: actionButton.nameField ?? 'name',
|
||||||
|
typeField: actionButton.typeField ?? 'type', contentField: actionButton.contentField ?? 'content',
|
||||||
|
operationName: actionButton.operationName, loadingStateName: actionButton.loadingStateName
|
||||||
|
};
|
||||||
|
switch (actionButton.type) {
|
||||||
|
case 'edit':
|
||||||
|
return <EditActionButton key={`action-${actionIndex}`} {...baseProps} onEdit={actionButton.onAction} hookData={hookData} />;
|
||||||
|
case 'delete':
|
||||||
|
return <DeleteActionButton key={`action-${actionIndex}`} {...baseProps} containerRef={{ current: actionButtonsRefs.current.get(index) || null }} hookData={hookData} />;
|
||||||
|
case 'view':
|
||||||
|
return <ViewActionButton key={`action-${actionIndex}`} {...baseProps} onView={actionButton.onAction || (() => {})} isViewing={isProcessing} hookData={hookData} />;
|
||||||
|
case 'copy':
|
||||||
|
return <CopyActionButton key={`action-${actionIndex}`} {...baseProps} onCopy={actionButton.onAction} isCopying={isProcessing} contentField={actionButton.contentField} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
{customActions.map((customAction) => (
|
||||||
|
<CustomActionButton key={`custom-${customAction.id}`} row={row} id={customAction.id} icon={customAction.icon}
|
||||||
|
onClick={customAction.onClick} visible={customAction.visible} disabled={customAction.disabled}
|
||||||
|
loading={customAction.loading} title={customAction.title} className={customAction.className}
|
||||||
|
hookData={hookData} idField={customAction.idField ?? 'id'} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{detectedColumns.map(column => {
|
||||||
|
const cellValue = row[column.key];
|
||||||
|
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
|
||||||
|
const combinedClassName = `${styles.td} ${customClassName}`.trim();
|
||||||
|
return (
|
||||||
|
<td key={column.key} className={combinedClassName}
|
||||||
|
style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150 }}>
|
||||||
|
{formatCellValue(cellValue, column, row)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
{!loading && displayData.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={(selectable ? 1 : 0) + (hasActionColumn ? 1 : 0) + detectedColumns.length}
|
||||||
|
style={{ textAlign: 'center', padding: '40px 16px', color: 'var(--text-muted, #64748b)' }}
|
||||||
|
>
|
||||||
|
{emptyMessage || t('formgen.empty', 'No data available')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
)}
|
|
||||||
</table>
|
</table>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { useNavigation } from '../../hooks/useNavigation';
|
||||||
import type {
|
import type {
|
||||||
DynamicBlock,
|
DynamicBlock,
|
||||||
NavigationItem,
|
NavigationItem,
|
||||||
|
NavSubgroup,
|
||||||
NavigationMandate,
|
NavigationMandate,
|
||||||
FeatureInstance,
|
FeatureInstance,
|
||||||
FeatureView
|
FeatureView
|
||||||
|
|
@ -174,7 +175,7 @@ export const MandateNavigation: React.FC = () => {
|
||||||
// Build navigation items from blocks
|
// Build navigation items from blocks
|
||||||
// Groups static items into collapsible containers:
|
// Groups static items into collapsible containers:
|
||||||
// - "Meine Sicht": all non-admin static items (Übersicht, Einstellungen, Prompts, etc.)
|
// - "Meine Sicht": all non-admin static items (Übersicht, Einstellungen, Prompts, etc.)
|
||||||
// - "Administration": all admin static items
|
// - "Administration": admin items, possibly with subgroups
|
||||||
// - Dynamic block (mandates) renders between them
|
// - Dynamic block (mandates) renders between them
|
||||||
const navigationItems: TreeItem[] = useMemo(() => {
|
const navigationItems: TreeItem[] = useMemo(() => {
|
||||||
const items: TreeItem[] = [];
|
const items: TreeItem[] = [];
|
||||||
|
|
@ -182,11 +183,16 @@ export const MandateNavigation: React.FC = () => {
|
||||||
// Collect static items by category
|
// Collect static items by category
|
||||||
const meineSichtItems: NavigationItem[] = [];
|
const meineSichtItems: NavigationItem[] = [];
|
||||||
let adminItems: NavigationItem[] = [];
|
let adminItems: NavigationItem[] = [];
|
||||||
|
let adminSubgroups: NavSubgroup[] = [];
|
||||||
|
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
if (block.type === 'static') {
|
if (block.type === 'static') {
|
||||||
if (block.id === 'admin') {
|
if (block.id === 'admin') {
|
||||||
adminItems = [...block.items];
|
if (block.subgroups && block.subgroups.length > 0) {
|
||||||
|
adminSubgroups = block.subgroups;
|
||||||
|
} else {
|
||||||
|
adminItems = [...block.items];
|
||||||
|
}
|
||||||
} else if (block.items.length > 0) {
|
} else if (block.items.length > 0) {
|
||||||
meineSichtItems.push(...block.items);
|
meineSichtItems.push(...block.items);
|
||||||
}
|
}
|
||||||
|
|
@ -209,8 +215,22 @@ export const MandateNavigation: React.FC = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Administration" - collapsible container for admin pages
|
// "Administration" - collapsible container for admin pages (with subgroup support)
|
||||||
if (adminItems.length > 0) {
|
if (adminSubgroups.length > 0) {
|
||||||
|
if (items.length > 0) items.push({ type: 'separator' });
|
||||||
|
const subgroupNodes: TreeNodeItem[] = adminSubgroups.map(sg => ({
|
||||||
|
id: sg.id,
|
||||||
|
label: sg.title,
|
||||||
|
children: sg.items.map(navigationItemToTreeNode),
|
||||||
|
defaultExpanded: false,
|
||||||
|
}));
|
||||||
|
items.push({
|
||||||
|
id: 'administration',
|
||||||
|
label: 'Administration',
|
||||||
|
children: subgroupNodes,
|
||||||
|
defaultExpanded: false,
|
||||||
|
});
|
||||||
|
} else if (adminItems.length > 0) {
|
||||||
if (items.length > 0) items.push({ type: 'separator' });
|
if (items.length > 0) items.push({ type: 'separator' });
|
||||||
items.push(_staticItemsToTreeNode('administration', 'Administration', adminItems, false));
|
items.push(_staticItemsToTreeNode('administration', 'Administration', adminItems, false));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,14 @@ export interface NavigationItem {
|
||||||
objectKey: string;
|
objectKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Subgroup within a static block (for nested navigation) */
|
||||||
|
export interface NavSubgroup {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
order: number;
|
||||||
|
items: NavigationItem[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Static navigation block */
|
/** Static navigation block */
|
||||||
export interface StaticBlock {
|
export interface StaticBlock {
|
||||||
type: 'static';
|
type: 'static';
|
||||||
|
|
@ -40,6 +48,7 @@ export interface StaticBlock {
|
||||||
title: string;
|
title: string;
|
||||||
order: number;
|
order: number;
|
||||||
items: NavigationItem[];
|
items: NavigationItem[];
|
||||||
|
subgroups?: NavSubgroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** View within a feature instance */
|
/** View within a feature instance */
|
||||||
|
|
|
||||||
|
|
@ -222,12 +222,12 @@ export const TeamsbotSettingsView: React.FC = () => {
|
||||||
value={formData.transferMode || 'auto'}
|
value={formData.transferMode || 'auto'}
|
||||||
onChange={(e) => _updateField('transferMode', e.target.value)}
|
onChange={(e) => _updateField('transferMode', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="auto">Automatisch - Anonym: Audio, Authentifiziert: Captions</option>
|
<option value="auto">Automatisch - System waehlt basierend auf Join-Modus</option>
|
||||||
<option value="caption">Captions - Teams-Transkript (Text aus Live-Captions)</option>
|
<option value="caption">Captions - Teams Live-Captions (nur Englisch verfuegbar)</option>
|
||||||
<option value="audio">Audio - Audio-Stream an Gateway (STT in eingestellter Sprache)</option>
|
<option value="audio">Audio-Stream - Echtzeit-Spracherkennung ueber Gateway (alle Sprachen)</option>
|
||||||
</select>
|
</select>
|
||||||
<span className={styles.hint}>
|
<span className={styles.hint}>
|
||||||
Bei anonymem Join liefert Teams nur englische Captions. Audio-Modus ermoeglicht STT in jeder Sprache ueber den Gateway.
|
Empfehlung: Audio-Stream fuer nicht-englische Meetings verwenden. Teams Live-Captions sind nur auf Englisch verfuegbar und oft ungenau.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -348,6 +348,21 @@ export const TeamsbotSettingsView: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
<span className={styles.hint}>URL des Browser Bot Service. Leer lassen fuer Standard-Konfiguration.</span>
|
<span className={styles.hint}>URL des Browser Bot Service. Leer lassen fuer Standard-Konfiguration.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.debugMode || false}
|
||||||
|
onChange={(e) => _updateField('debugMode', e.target.checked)}
|
||||||
|
style={{ width: '18px', height: '18px' }}
|
||||||
|
/>
|
||||||
|
<span className={styles.label} style={{ margin: 0 }}>Debug-Modus</span>
|
||||||
|
</label>
|
||||||
|
<span className={styles.hint}>
|
||||||
|
Aktiviert Screenshot-Erfassung bei jedem Join-Schritt fuer Diagnose. Screenshots koennen in der Session-Ansicht eingesehen werden.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Save Button */}
|
{/* Save Button */}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue