feat: add debug mode checkbox, update transfer mode descriptions
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2cf296dee6
commit
b5a9ee2d4a
7 changed files with 581 additions and 203 deletions
|
|
@ -66,6 +66,7 @@ export interface TeamsbotConfig {
|
|||
triggerIntervalSeconds: number;
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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) &&
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
return (
|
||||
<td
|
||||
key={column.key}
|
||||
className={styles.td}
|
||||
style={{
|
||||
width: columnWidths[column.key] || column.width || 150,
|
||||
minWidth: columnWidths[column.key] || column.width || 150,
|
||||
maxWidth: columnWidths[column.key] || column.width || 150,
|
||||
fontWeight: hasContent ? 600 : 'normal',
|
||||
}}
|
||||
>
|
||||
{showExpandIcon && (
|
||||
<span style={{
|
||||
display: 'inline-flex', fontSize: '11px', transition: 'transform 0.2s',
|
||||
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
color: 'var(--text-muted, #64748b)', marginRight: '6px',
|
||||
}}>▶</span>
|
||||
)}
|
||||
{hasContent ? cellContent : ''}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
{isExpanded && groupRows.map((row, rowIndex) => {
|
||||
const globalIndex = displayData.indexOf(row);
|
||||
const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, globalIndex) : {};
|
||||
return (
|
||||
<tr
|
||||
key={`${groupKey}-row-${rowIndex}`}
|
||||
className={`${styles.tr} ${selectedRows.has(globalIndex) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
||||
onClick={() => onRowClick?.(row, globalIndex)}
|
||||
{...Object.fromEntries(
|
||||
Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value])
|
||||
)}
|
||||
>
|
||||
{selectable && (
|
||||
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRows.has(globalIndex)}
|
||||
onChange={() => handleRowSelect(globalIndex)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={isRowSelectable && !isRowSelectable(row)}
|
||||
title={
|
||||
isRowSelectable && !isRowSelectable(row)
|
||||
? t('formgen.select.disabled', 'This item cannot be selected')
|
||||
: t('formgen.select.item', 'Select this item')
|
||||
}
|
||||
style={{
|
||||
opacity: isRowSelectable && !isRowSelectable(row) ? 0.4 : 1,
|
||||
cursor: isRowSelectable && !isRowSelectable(row) ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{hasActionColumn && (
|
||||
<td
|
||||
className={styles.actionsColumn}
|
||||
style={{
|
||||
width: `${currentActionsWidth}px`,
|
||||
minWidth: `${defaultActionsWidth}px`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
actionButtonsRefs.current.set(globalIndex, el);
|
||||
} else {
|
||||
actionButtonsRefs.current.delete(globalIndex);
|
||||
}
|
||||
}}
|
||||
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}
|
||||
>
|
||||
{actionButtons.map((actionButton, actionIndex) => {
|
||||
const actionTitle = typeof actionButton.title === 'function'
|
||||
? actionButton.title(row)
|
||||
: actionButton.title;
|
||||
let disabledResult: boolean | { disabled: boolean; message?: string } = false;
|
||||
if (actionButton.disabled) {
|
||||
disabledResult = actionButton.disabled(row, hookData);
|
||||
} else if (row._permissions) {
|
||||
if (actionButton.type === 'edit' && row._permissions.canUpdate === false) {
|
||||
disabledResult = true;
|
||||
} else if (actionButton.type === 'delete' && row._permissions.canDelete === false) {
|
||||
disabledResult = true;
|
||||
}
|
||||
}
|
||||
const isLoading = actionButton.loading ? actionButton.loading(row) : false;
|
||||
const isProcessing = actionButton.isProcessing ? actionButton.isProcessing(row) : false;
|
||||
const baseProps = {
|
||||
row, disabled: disabledResult, loading: isLoading,
|
||||
className: actionButton.className, title: actionTitle,
|
||||
idField: actionButton.idField ?? 'id', nameField: actionButton.nameField ?? 'name',
|
||||
typeField: actionButton.typeField ?? 'type', contentField: actionButton.contentField ?? 'content',
|
||||
operationName: actionButton.operationName, loadingStateName: actionButton.loadingStateName
|
||||
};
|
||||
switch (actionButton.type) {
|
||||
case 'edit':
|
||||
return <EditActionButton key={`action-${actionIndex}`} {...baseProps} onEdit={actionButton.onAction} hookData={hookData} />;
|
||||
case 'delete':
|
||||
return <DeleteActionButton key={`action-${actionIndex}`} {...baseProps} containerRef={{ current: actionButtonsRefs.current.get(globalIndex) || null }} hookData={hookData} />;
|
||||
case 'view':
|
||||
return <ViewActionButton key={`action-${actionIndex}`} {...baseProps} onView={actionButton.onAction || (() => {})} isViewing={isProcessing} hookData={hookData} />;
|
||||
case 'copy':
|
||||
return <CopyActionButton key={`action-${actionIndex}`} {...baseProps} onCopy={actionButton.onAction} isCopying={isProcessing} contentField={actionButton.contentField} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
{customActions.map((customAction) => (
|
||||
<CustomActionButton key={`custom-${customAction.id}`} row={row} id={customAction.id} icon={customAction.icon}
|
||||
onClick={customAction.onClick} visible={customAction.visible} disabled={customAction.disabled}
|
||||
loading={customAction.loading} title={customAction.title} className={customAction.className}
|
||||
hookData={hookData} idField={customAction.idField ?? 'id'} />
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
{detectedColumns.map(column => {
|
||||
const cellValue = row[column.key];
|
||||
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
|
||||
const combinedClassName = `${styles.td} ${customClassName}`.trim();
|
||||
return (
|
||||
<td key={column.key} className={combinedClassName}
|
||||
style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150 }}>
|
||||
{formatCellValue(cellValue, column, row)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
})
|
||||
) : (
|
||||
displayData.map((row, index) => {
|
||||
const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {};
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className={`${styles.tr} ${selectedRows.has(index) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
||||
onClick={() => onRowClick?.(row, index)}
|
||||
{...Object.fromEntries(
|
||||
Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value])
|
||||
)}
|
||||
>
|
||||
{selectable && (
|
||||
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRows.has(index)}
|
||||
onChange={() => handleRowSelect(index)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={isRowSelectable && !isRowSelectable(row)}
|
||||
title={
|
||||
isRowSelectable && !isRowSelectable(row)
|
||||
? t('formgen.select.disabled', 'This item cannot be selected')
|
||||
: t('formgen.select.item', 'Select this item')
|
||||
}
|
||||
style={{
|
||||
opacity: isRowSelectable && !isRowSelectable(row) ? 0.4 : 1,
|
||||
cursor: isRowSelectable && !isRowSelectable(row) ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{hasActionColumn && (
|
||||
<td
|
||||
className={styles.actionsColumn}
|
||||
style={{
|
||||
width: `${currentActionsWidth}px`,
|
||||
minWidth: `${defaultActionsWidth}px`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
actionButtonsRefs.current.set(index, el);
|
||||
} else {
|
||||
actionButtonsRefs.current.delete(index);
|
||||
}
|
||||
}}
|
||||
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}
|
||||
>
|
||||
{actionButtons.map((actionButton, actionIndex) => {
|
||||
const actionTitle = typeof actionButton.title === 'function'
|
||||
? actionButton.title(row)
|
||||
: actionButton.title;
|
||||
let disabledResult: boolean | { disabled: boolean; message?: string } = false;
|
||||
if (actionButton.disabled) {
|
||||
disabledResult = actionButton.disabled(row, hookData);
|
||||
} else if (row._permissions) {
|
||||
if (actionButton.type === 'edit' && row._permissions.canUpdate === false) {
|
||||
disabledResult = true;
|
||||
} else if (actionButton.type === 'delete' && row._permissions.canDelete === false) {
|
||||
disabledResult = true;
|
||||
}
|
||||
}
|
||||
const isLoading = actionButton.loading ? actionButton.loading(row) : false;
|
||||
const isProcessing = actionButton.isProcessing ? actionButton.isProcessing(row) : false;
|
||||
const baseProps = {
|
||||
row, disabled: disabledResult, loading: isLoading,
|
||||
className: actionButton.className, title: actionTitle,
|
||||
idField: actionButton.idField ?? 'id', nameField: actionButton.nameField ?? 'name',
|
||||
typeField: actionButton.typeField ?? 'type', contentField: actionButton.contentField ?? 'content',
|
||||
operationName: actionButton.operationName, loadingStateName: actionButton.loadingStateName
|
||||
};
|
||||
switch (actionButton.type) {
|
||||
case 'edit':
|
||||
return <EditActionButton key={`action-${actionIndex}`} {...baseProps} onEdit={actionButton.onAction} hookData={hookData} />;
|
||||
case 'delete':
|
||||
return <DeleteActionButton key={`action-${actionIndex}`} {...baseProps} containerRef={{ current: actionButtonsRefs.current.get(index) || null }} hookData={hookData} />;
|
||||
case 'view':
|
||||
return <ViewActionButton key={`action-${actionIndex}`} {...baseProps} onView={actionButton.onAction || (() => {})} isViewing={isProcessing} hookData={hookData} />;
|
||||
case 'copy':
|
||||
return <CopyActionButton key={`action-${actionIndex}`} {...baseProps} onCopy={actionButton.onAction} isCopying={isProcessing} contentField={actionButton.contentField} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
{customActions.map((customAction) => (
|
||||
<CustomActionButton key={`custom-${customAction.id}`} row={row} id={customAction.id} icon={customAction.icon}
|
||||
onClick={customAction.onClick} visible={customAction.visible} disabled={customAction.disabled}
|
||||
loading={customAction.loading} title={customAction.title} className={customAction.className}
|
||||
hookData={hookData} idField={customAction.idField ?? 'id'} />
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
{detectedColumns.map(column => {
|
||||
const cellValue = row[column.key];
|
||||
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
|
||||
const combinedClassName = `${styles.td} ${customClassName}`.trim();
|
||||
return (
|
||||
<td key={column.key} className={combinedClassName}
|
||||
style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150 }}>
|
||||
{formatCellValue(cellValue, column, row)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{!loading && displayData.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={(selectable ? 1 : 0) + (hasActionColumn ? 1 : 0) + detectedColumns.length}
|
||||
style={{ textAlign: 'center', padding: '40px 16px', color: 'var(--text-muted, #64748b)' }}
|
||||
>
|
||||
{emptyMessage || t('formgen.empty', 'No data available')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
)}
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue