From b5a9ee2d4ab4dadf0d24878a085bed2737e41129 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Wed, 18 Feb 2026 23:52:52 +0100 Subject: [PATCH] feat: add debug mode checkbox, update transfer mode descriptions Co-authored-by: Cursor --- src/api/teamsbotApi.ts | 2 + .../FormGeneratorForm/FormGeneratorForm.tsx | 19 +- .../FormGeneratorTable.module.css | 38 + .../FormGeneratorTable/FormGeneratorTable.tsx | 665 +++++++++++++----- .../Navigation/MandateNavigation.tsx | 28 +- src/hooks/useNavigation.ts | 9 + .../views/teamsbot/TeamsbotSettingsView.tsx | 23 +- 7 files changed, 581 insertions(+), 203 deletions(-) diff --git a/src/api/teamsbotApi.ts b/src/api/teamsbotApi.ts index dac7219..165bf5c 100644 --- a/src/api/teamsbotApi.ts +++ b/src/api/teamsbotApi.ts @@ -66,6 +66,7 @@ export interface TeamsbotConfig { triggerIntervalSeconds: number; triggerCooldownSeconds: number; contextWindowSegments: number; + debugMode?: boolean; } export interface TeamsbotSessionStats { @@ -96,6 +97,7 @@ export interface ConfigUpdateRequest { triggerIntervalSeconds?: number; triggerCooldownSeconds?: number; contextWindowSegments?: number; + debugMode?: boolean; } // Voice/Language Types (from Google TTS API) diff --git a/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx index 5957c7b..a4fc05f 100644 --- a/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx +++ b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx @@ -47,6 +47,8 @@ export interface AttributeDefinition { placeholder?: string; minRows?: number; // For textarea types maxRows?: number; // For textarea types + editableOnCreate?: boolean; + editableOnUpdate?: boolean; } export interface AttributeOption { @@ -86,6 +88,17 @@ export interface FormGeneratorFormProps { 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 export function FormGeneratorForm>({ entityType, @@ -193,7 +206,7 @@ export function FormGeneratorForm>({ filtered = filtered.filter(attr => attr.visible !== false); } else if (mode === 'create') { // 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') { filtered = filtered.filter(attr => attr.visible !== false); } @@ -630,7 +643,7 @@ export function FormGeneratorForm>({ const renderMultilingualField = (attr: AttributeDefinition) => { const value = formData[attr.name] || { en: '' }; 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 const multilingualValue = isTextMultilingual(value) ? value : { en: typeof value === 'string' ? value : '' }; @@ -697,7 +710,7 @@ export function FormGeneratorForm>({ const renderField = (attr: AttributeDefinition) => { const value = formData[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 const shouldRenderAsMultilingual = isMultilingualType(attr.type as AttributeType) && diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css index 7668a13..d5cae60 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css @@ -918,6 +918,44 @@ tbody .actionsColumn { @keyframes booleanPulse { 0%, 100% { opacity: 0.4; } 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; } diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 497cd83..8374550 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -157,6 +157,12 @@ export interface FormGeneratorTableProps { emptyMessage?: string; // API endpoint for CSV export (e.g. "/api/users/"). If provided, the CSV export button is shown. apiEndpoint?: string; + // Grouping configuration + groupBy?: string; + groupRenderer?: (groupKey: string, groupRows: T[], isExpanded: boolean) => React.ReactNode; + groupRowData?: (groupKey: string, groupRows: T[]) => Record; + groupDefaultExpanded?: boolean; + groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode; } export function FormGeneratorTable>({ @@ -188,7 +194,12 @@ export function FormGeneratorTable>({ getRowDataAttributes, hookData, emptyMessage, - apiEndpoint + apiEndpoint, + groupBy, + groupRenderer: _groupRenderer, + groupRowData, + groupDefaultExpanded = true, + groupActions }: FormGeneratorTableProps) { const { t, currentLanguage: contextLanguage } = useLanguage(); // Map frontend language codes (de/en/fr) to backend codes (ge/en/fr) for multilingual field resolution @@ -241,6 +252,10 @@ export function FormGeneratorTable>({ const [openFilterColumn, setOpenFilterColumn] = useState(null); const filterDropdownRef = useRef(null); + // Grouping: Track expanded groups + const [expandedGroups, setExpandedGroups] = useState>(() => new Set()); + const [groupsInitialized, setGroupsInitialized] = useState(false); + // FK Resolution: Cache for resolved FK values (fkSource -> { id -> displayLabel }) const [fkCache, setFkCache] = useState({}); const [fkLoading, setFkLoading] = useState>({}); @@ -340,6 +355,9 @@ export function FormGeneratorTable>({ // Track container width for actions column 20% threshold const [containerWidth, setContainerWidth] = useState(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 // Minimum width always fits 4 icons (4 * 26px button + 3 * 2px gap + 8px padding = 122px) const MIN_ACTIONS_WIDTH_FOR_4_ICONS = 122; @@ -599,6 +617,52 @@ export function FormGeneratorTable>({ // No client-side processing needed 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(); + 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 const totalPages = useMemo(() => { // If pagination object exists, use totalPages from backend @@ -702,15 +766,73 @@ export function FormGeneratorTable>({ return Object.keys(filters).filter(k => filters[k] !== undefined && filters[k] !== '').length; }, [filters]); + // Track which filter columns show all values (expanded beyond initial 100) + const [expandedFilterColumns, setExpandedFilterColumns] = useState>(new Set()); + // Async-loaded filter values per column (from backend via hookData.fetchFilterValues) + const [asyncFilterValues, setAsyncFilterValues] = useState>({}); + const [filterValuesLoading, setFilterValuesLoading] = useState>({}); + + 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) + // Priority: 1) column.filterOptions (static enum) + // 2) asyncFilterValues (loaded from backend) + // 3) data (current page - fallback) 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(); data.forEach(row => { const value = row[columnKey]; if (value !== undefined && value !== null && value !== '') { - // Handle different value types if (typeof value === 'object' && !Array.isArray(value)) { - // For objects (like TextMultilingual), get the string representation if (isTextMultilingual(value)) { const text = value.en || Object.values(value)[0]; if (text) values.add(String(text)); @@ -725,7 +847,7 @@ export function FormGeneratorTable>({ } }); return Array.from(values).sort(); - }, [data]); + }, [data, detectedColumns, asyncFilterValues]); // Close filter dropdown when clicking outside useEffect(() => { @@ -1565,13 +1687,7 @@ export function FormGeneratorTable>({ )} - {/* Empty state - only shown when not loading AND no data */} - {!loading && displayData.length === 0 ? ( -
-

{emptyMessage || t('formgen.empty', 'No data available')}

-
- ) : ( - +
{selectable && ( @@ -1590,7 +1706,7 @@ export function FormGeneratorTable>({ /> )} - {actionButtons.length > 0 && ( + {hasActionColumn && ( - {displayData.length > 0 && ( - {displayData.map((row, index) => { - const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {}; - return ( - onRowClick?.(row, index)} - {...Object.fromEntries( - Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value]) - )} - > - {selectable && ( - - )} - {actionButtons.length > 0 && ( - - )} - {detectedColumns.map(column => { - const cellValue = row[column.key]; - const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : ''; - const combinedClassName = `${styles.td} ${customClassName}`.trim(); + {groupBy && groupedData ? ( + Array.from(groupedData.entries()).map(([groupKey, groupRows]) => { + const isExpanded = expandedGroups.has(groupKey); + const cellData = groupRowData ? groupRowData(groupKey, groupRows) : null; return ( - + + toggleGroup(groupKey)} + style={{ + cursor: 'pointer', + backgroundColor: 'var(--bg-secondary, #f8fafc)', + borderBottom: '2px solid var(--border-color, #e2e8f0)' + }} + > + {selectable && ( + + )} + {hasActionColumn && ( + + )} + {detectedColumns.map((column, colIdx) => { + const cellContent = cellData?.[column.key]; + const hasContent = cellContent !== undefined && cellContent !== null; + const showExpandIcon = !selectable && actionButtons.length === 0 && colIdx === 0; + + return ( + + ); + })} + + {isExpanded && groupRows.map((row, rowIndex) => { + const globalIndex = displayData.indexOf(row); + const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, globalIndex) : {}; + return ( + onRowClick?.(row, globalIndex)} + {...Object.fromEntries( + Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value]) + )} + > + {selectable && ( + + )} + {hasActionColumn && ( + + )} + {detectedColumns.map(column => { + const cellValue = row[column.key]; + const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : ''; + const combinedClassName = `${styles.td} ${customClassName}`.trim(); + return ( + + ); + })} + + ); + })} + ); - })} - - - ); - })} + }) + ) : ( + displayData.map((row, index) => { + const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {}; + return ( + onRowClick?.(row, index)} + {...Object.fromEntries( + Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value]) + )} + > + {selectable && ( + + )} + {hasActionColumn && ( + + )} + {detectedColumns.map(column => { + const cellValue = row[column.key]; + const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : ''; + const combinedClassName = `${styles.td} ${customClassName}`.trim(); + return ( + + ); + })} + + ); + }) + )} + {!loading && displayData.length === 0 && ( + + + + )} - )}
>({ > ({t('formgen.filter.all', 'All')}) - {/* Unique values from data */} - {getUniqueValuesForColumn(column.key).slice(0, 50).map(value => ( -
handleFilter(column.key, value)} - title={value} - > - {value.length > 30 ? value.substring(0, 30) + '...' : value} + {/* Filter values - loaded from backend or static filterOptions */} + {filterValuesLoading[column.key] ? ( +
+ {t('formgen.filter.loading', 'Lade Filterwerte...')}
- ))} - {getUniqueValuesForColumn(column.key).length > 50 && ( -
- ... {t('formgen.filter.more', 'and {count} more').replace('{count}', String(getUniqueValuesForColumn(column.key).length - 50))} -
- )} + ) : (() => { + const allValues = getUniqueValuesForColumn(column.key); + const isExpanded = expandedFilterColumns.has(column.key); + const displayLimit = isExpanded ? allValues.length : 100; + const visibleValues = allValues.slice(0, displayLimit); + const remaining = allValues.length - displayLimit; + + return ( + <> + {visibleValues.map(value => ( +
handleFilter(column.key, value)} + title={value} + > + {value.length > 30 ? value.substring(0, 30) + '...' : value} +
+ ))} + {remaining > 0 && ( +
_toggleFilterExpand(column.key)} + style={{ cursor: 'pointer', color: 'var(--primary-blue, #003d7a)' }} + > + + {remaining} {t('formgen.filter.more', 'weitere anzeigen')} +
+ )} + {isExpanded && allValues.length > 100 && ( +
_toggleFilterExpand(column.key)} + style={{ cursor: 'pointer', color: 'var(--primary-blue, #003d7a)' }} + > + {t('formgen.filter.less', 'Weniger anzeigen')} +
+ )} + + ); + })()}
)} @@ -1719,177 +1864,313 @@ export function FormGeneratorTable>({ ))}
- 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' - }} - /> - -
{ - 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 ; - case 'delete': - return ; - case 'view': - return {})} - isViewing={isProcessing} - hookData={hookData} - />; - case 'copy': - return ; - default: - return null; - } - })} - {/* Custom action buttons (entity-specific actions) */} - {customActions.map((customAction) => ( - - ))} -
-
- {formatCellValue(cellValue, column, row)} -
+ + + {!selectable && ( + + )} + {groupActions && ( + e.stopPropagation()}> + {groupActions(groupKey, groupRows)} + + )} + + {showExpandIcon && ( + + )} + {hasContent ? cellContent : ''} +
+ 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' + }} + /> + +
{ + 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 ; + case 'delete': + return ; + case 'view': + return {})} isViewing={isProcessing} hookData={hookData} />; + case 'copy': + return ; + default: + return null; + } + })} + {customActions.map((customAction) => ( + + ))} +
+
+ {formatCellValue(cellValue, column, row)} +
+ 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' + }} + /> + +
{ + 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 ; + case 'delete': + return ; + case 'view': + return {})} isViewing={isProcessing} hookData={hookData} />; + case 'copy': + return ; + default: + return null; + } + })} + {customActions.map((customAction) => ( + + ))} +
+
+ {formatCellValue(cellValue, column, row)} +
+ {emptyMessage || t('formgen.empty', 'No data available')} +
- )} diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx index dc9ffb5..e8cc3bc 100644 --- a/src/components/Navigation/MandateNavigation.tsx +++ b/src/components/Navigation/MandateNavigation.tsx @@ -25,6 +25,7 @@ import { useNavigation } from '../../hooks/useNavigation'; import type { DynamicBlock, NavigationItem, + NavSubgroup, NavigationMandate, FeatureInstance, FeatureView @@ -174,7 +175,7 @@ export const MandateNavigation: React.FC = () => { // Build navigation items from blocks // Groups static items into collapsible containers: // - "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 const navigationItems: TreeItem[] = useMemo(() => { const items: TreeItem[] = []; @@ -182,11 +183,16 @@ export const MandateNavigation: React.FC = () => { // Collect static items by category const meineSichtItems: NavigationItem[] = []; let adminItems: NavigationItem[] = []; + let adminSubgroups: NavSubgroup[] = []; for (const block of blocks) { if (block.type === 'static') { 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) { meineSichtItems.push(...block.items); } @@ -209,8 +215,22 @@ export const MandateNavigation: React.FC = () => { } } - // "Administration" - collapsible container for admin pages - if (adminItems.length > 0) { + // "Administration" - collapsible container for admin pages (with subgroup support) + 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' }); items.push(_staticItemsToTreeNode('administration', 'Administration', adminItems, false)); } diff --git a/src/hooks/useNavigation.ts b/src/hooks/useNavigation.ts index 2b6c4a0..7450bf6 100644 --- a/src/hooks/useNavigation.ts +++ b/src/hooks/useNavigation.ts @@ -33,6 +33,14 @@ export interface NavigationItem { objectKey: string; } +/** Subgroup within a static block (for nested navigation) */ +export interface NavSubgroup { + id: string; + title: string; + order: number; + items: NavigationItem[]; +} + /** Static navigation block */ export interface StaticBlock { type: 'static'; @@ -40,6 +48,7 @@ export interface StaticBlock { title: string; order: number; items: NavigationItem[]; + subgroups?: NavSubgroup[]; } /** View within a feature instance */ diff --git a/src/pages/views/teamsbot/TeamsbotSettingsView.tsx b/src/pages/views/teamsbot/TeamsbotSettingsView.tsx index 2e44afc..b77d66f 100644 --- a/src/pages/views/teamsbot/TeamsbotSettingsView.tsx +++ b/src/pages/views/teamsbot/TeamsbotSettingsView.tsx @@ -222,12 +222,12 @@ export const TeamsbotSettingsView: React.FC = () => { value={formData.transferMode || 'auto'} onChange={(e) => _updateField('transferMode', e.target.value)} > - - - + + + - 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. @@ -348,6 +348,21 @@ export const TeamsbotSettingsView: React.FC = () => { /> URL des Browser Bot Service. Leer lassen fuer Standard-Konfiguration. + +
+ + + Aktiviert Screenshot-Erfassung bei jedem Join-Schritt fuer Diagnose. Screenshots koennen in der Session-Ansicht eingesehen werden. + +
{/* Save Button */}