feat: add debug mode checkbox, update transfer mode descriptions

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ValueOn AG 2026-02-18 23:52:52 +01:00
parent 2cf296dee6
commit b5a9ee2d4a
7 changed files with 581 additions and 203 deletions

View file

@ -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)

View file

@ -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<T = any> {
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<T extends Record<string, any>>({
entityType,
@ -193,7 +206,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
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<T extends Record<string, any>>({
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<T extends Record<string, any>>({
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) &&

View file

@ -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;
}

View file

@ -157,6 +157,12 @@ export interface FormGeneratorTableProps<T = any> {
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<string, React.ReactNode>;
groupDefaultExpanded?: boolean;
groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode;
}
export function FormGeneratorTable<T extends Record<string, any>>({
@ -188,7 +194,12 @@ export function FormGeneratorTable<T extends Record<string, any>>({
getRowDataAttributes,
hookData,
emptyMessage,
apiEndpoint
apiEndpoint,
groupBy,
groupRenderer: _groupRenderer,
groupRowData,
groupDefaultExpanded = true,
groupActions
}: FormGeneratorTableProps<T>) {
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<T extends Record<string, any>>({
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(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 })
const [fkCache, setFkCache] = useState<FkCacheType>({});
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
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
// 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<T extends Record<string, any>>({
// 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<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
const totalPages = useMemo(() => {
// 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;
}, [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)
// 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<string>();
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<T extends Record<string, any>>({
}
});
return Array.from(values).sort();
}, [data]);
}, [data, detectedColumns, asyncFilterValues]);
// Close filter dropdown when clicking outside
useEffect(() => {
@ -1565,13 +1687,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
</div>
)}
{/* Empty state - only shown when not loading AND no data */}
{!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}>
<table ref={tableRef} className={styles.table}>
<thead>
<tr>
{selectable && (
@ -1590,7 +1706,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
/>
</th>
)}
{actionButtons.length > 0 && (
{hasActionColumn && (
<th
className={styles.actionsColumn}
style={{
@ -1689,22 +1805,51 @@ export function FormGeneratorTable<T extends Record<string, any>>({
>
({t('formgen.filter.all', 'All')})
</div>
{/* Unique values from data */}
{getUniqueValuesForColumn(column.key).slice(0, 50).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}
{/* Filter values - loaded from backend or static filterOptions */}
{filterValuesLoading[column.key] ? (
<div className={styles.filterOptionMore} style={{ textAlign: 'center', padding: '8px' }}>
{t('formgen.filter.loading', 'Lade Filterwerte...')}
</div>
))}
{getUniqueValuesForColumn(column.key).length > 50 && (
<div className={styles.filterOptionMore}>
... {t('formgen.filter.more', 'and {count} more').replace('{count}', String(getUniqueValuesForColumn(column.key).length - 50))}
</div>
)}
) : (() => {
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 => (
<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>
)}
@ -1719,177 +1864,313 @@ export function FormGeneratorTable<T extends Record<string, any>>({
))}
</tr>
</thead>
{displayData.length > 0 && (
<tbody>
{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>
)}
{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();
{groupBy && groupedData ? (
Array.from(groupedData.entries()).map(([groupKey, groupRows]) => {
const isExpanded = expandedGroups.has(groupKey);
const cellData = groupRowData ? groupRowData(groupKey, groupRows) : null;
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>
);
})}
<React.Fragment key={`group-${groupKey}`}>
<tr
className={`${styles.tr} ${styles.groupHeader}`}
onClick={() => toggleGroup(groupKey)}
style={{
cursor: 'pointer',
backgroundColor: 'var(--bg-secondary, #f8fafc)',
borderBottom: '2px solid var(--border-color, #e2e8f0)'
}}
>
{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;
</tr>
);
})}
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>
);
})
) : (
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>
)}
</table>
)}
</div>
</div>
</div>

View file

@ -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));
}

View file

@ -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 */

View file

@ -222,12 +222,12 @@ export const TeamsbotSettingsView: React.FC = () => {
value={formData.transferMode || 'auto'}
onChange={(e) => _updateField('transferMode', e.target.value)}
>
<option value="auto">Automatisch - Anonym: Audio, Authentifiziert: Captions</option>
<option value="caption">Captions - Teams-Transkript (Text aus Live-Captions)</option>
<option value="audio">Audio - Audio-Stream an Gateway (STT in eingestellter Sprache)</option>
<option value="auto">Automatisch - System waehlt basierend auf Join-Modus</option>
<option value="caption">Captions - Teams Live-Captions (nur Englisch verfuegbar)</option>
<option value="audio">Audio-Stream - Echtzeit-Spracherkennung ueber Gateway (alle Sprachen)</option>
</select>
<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>
</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>
</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>
{/* Save Button */}