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

View file

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

View file

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

View file

@ -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;
</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> </tbody>
)}
</table> </table>
)}
</div> </div>
</div> </div>
</div> </div>

View file

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

View file

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

View file

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