From e7a79a3484b349cb331784470d9b2cbf0218bee0 Mon Sep 17 00:00:00 2001 From: Ida Date: Thu, 30 Apr 2026 10:46:44 +0200 Subject: [PATCH] UI Verbesserungen Gruppierung und Anwendung auf alle Seiten --- .../FormGeneratorTable.module.css | 50 +++++++++ .../FormGeneratorTable/FormGeneratorTable.tsx | 83 +++++++++++--- .../GroupingManager/GroupRow.module.css | 11 ++ .../GroupingManager/GroupRow.tsx | 101 ++++++++++++++++-- src/hooks/useFiles.ts | 6 ++ src/hooks/usePrompts.ts | 8 +- src/pages/basedata/FilesPage.tsx | 3 + src/pages/basedata/PromptsPage.tsx | 3 + 8 files changed, 241 insertions(+), 24 deletions(-) diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css index 165ac34..a9d3b5c 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css @@ -486,6 +486,56 @@ cursor: pointer; } +/* Items that live inside a group — subtle tint + left connector */ +.tr.groupedItem { + border-left: 3px solid color-mix(in srgb, var(--color-primary, #4a6fa5) 35%, transparent); +} + +.tr.groupedItem:hover { + background: color-mix(in srgb, var(--color-primary, #4a6fa5) 8%, var(--color-bg, #fff)); +} + +/** + * Hierarchy: set `--row-tree-indent` on the (px). Same row shifts checkbox, actions, and every `.td`. + * Folder rows attach this class from GroupRow.tsx; omit padding on `.folderCell` (inner strip uses `--group-indent`). + */ +.treeRowIndented { + --row-tree-indent: 0px; +} + +.treeRowIndented > .selectColumn { + box-sizing: border-box !important; + padding-top: 4px !important; + padding-right: 4px !important; + padding-bottom: 4px !important; + padding-left: calc(4px + var(--row-tree-indent)) !important; +} + +.treeRowIndented > .actionsColumn { + box-sizing: border-box !important; + padding-top: 4px !important; + padding-right: 4px !important; + padding-bottom: 4px !important; + padding-left: calc(4px + var(--row-tree-indent)) !important; +} + +.treeRowIndented > .td { + box-sizing: border-box !important; + padding-top: 8px !important; + padding-right: 12px !important; + padding-bottom: 8px !important; + padding-left: calc(12px + var(--row-tree-indent)) !important; +} + +.treeRowIndented > .folderCell:first-child { + box-sizing: border-box !important; + padding-left: calc(12px + var(--row-tree-indent)) !important; +} + +.treeRowIndented > .selectColumn + .folderCell { + padding: 0 !important; +} + /* Selection Column */ .selectColumn { text-align: center; diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 3a8b6d0..f6b603e 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -76,11 +76,15 @@ import { } from '../../../utils/attributeTypeMapper'; import type { AttributeType } from '../../../utils/attributeTypeMapper'; import { FaFilter, FaTrash } from 'react-icons/fa'; -import type { GroupBulkAction } from '../GroupingManager/GroupRow'; import api from '../../../api'; import { PeriodPicker } from '../../PeriodPicker'; import type { PeriodValue } from '../../PeriodPicker'; -import { GroupFolderRow, BreadcrumbRow } from '../GroupingManager/GroupRow'; +import { + GroupFolderRow, + BreadcrumbRow, + GROUP_TREE_INDENT_STEP_PX, + type GroupBulkAction, +} from '../GroupingManager/GroupRow'; /** A filter value can be a plain string, null (for empty/missing), or a * {value, label} object returned by FK-aware filter-values endpoints. */ @@ -3058,17 +3062,23 @@ export function FormGeneratorTable>({ const rowId = _getRowId(row); const isDragging = draggedRowId === rowId; const willUngroup = isDragging && dragWillUngroup; - const indentStyle: React.CSSProperties = indentLevel > 0 - ? { borderLeft: `3px solid color-mix(in srgb, var(--color-primary, #4a6fa5) ${Math.min(indentLevel * 25, 60)}%, transparent)` } + const isGrouped = indentLevel > 0; + const rowBgStyle: React.CSSProperties = isGrouped + ? { background: 'color-mix(in srgb, var(--color-primary, #4a6fa5) 4%, transparent)' } : {}; const ungroupStyle: React.CSSProperties = willUngroup - ? { borderLeft: '3px solid #d69e2e', opacity: 0.5 } + ? { outline: '2px solid #d69e2e', opacity: 0.5 } : {}; + /** Visual depth inside group tree (= folder depth + 1 for direct children — see `_renderGroup`). */ + const leadingIndentPx = isGrouped ? indentLevel * GROUP_TREE_INDENT_STEP_PX : 0; + const treeIndentCss: React.CSSProperties | undefined = + isGrouped ? { ['--row-tree-indent' as string]: `${leadingIndentPx}px` } : undefined; + return ( onRowClick?.(row, index)} draggable={groupingEnabled || rowDraggable} onDragStart={(e) => { @@ -3111,7 +3121,10 @@ export function FormGeneratorTable>({ {...Object.fromEntries(Object.entries(dataAttributes).map(([k, v]) => [`data-${k}`, v]))} > {selectable && ( - + handleRowSelect(row)} @@ -3123,7 +3136,10 @@ export function FormGeneratorTable>({ )} {hasActionColumn && ( - +
{ if (el) actionButtonsRefs.current.set(index, el); else actionButtonsRefs.current.delete(index); }} className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}> {actionButtons.map((ab, ai) => { @@ -3155,13 +3171,21 @@ export function FormGeneratorTable>({
)} - {detectedColumns.map(col => { + {detectedColumns.map((col) => { const cv = row[col.key]; const cCls = col.cellClassName ? col.cellClassName(cv, row) : ''; const aStyle = _columnAlignStyle(col); return ( - + {formatCellValue(cv, col, row)} ); @@ -3181,9 +3205,25 @@ export function FormGeneratorTable>({ const _visibleById = new Map(); displayData.forEach(row => _visibleById.set(_getRowId(row), row)); + const _collectSelectableSubtreeIds = (n: typeof groupTree[0]): string[] => { + const acc: string[] = []; + for (const id of n.itemIds) { + const r = _visibleById.get(id); + if (!r) continue; + if (isRowSelectable && !isRowSelectable(r)) continue; + acc.push(id); + } + for (const sg of n.subGroups) acc.push(..._collectSelectableSubtreeIds(sg)); + return acc; + }; + const _renderGroup = (node: typeof groupTree[0], depth: number, inheritedHidden = false): React.ReactNode => { const visibleIds = node.itemIds.filter(id => _visibleById.has(id)); const groupItems = visibleIds.map(id => _visibleById.get(id)!); + const subtreeSelectableIds = _collectSelectableSubtreeIds(node); + const subtreeSelectedCount = subtreeSelectableIds.filter(id => selectedIds.has(id)).length; + const subtreeAllSelected = subtreeSelectableIds.length > 0 && subtreeSelectedCount === subtreeSelectableIds.length; + const subtreePartial = subtreeSelectedCount > 0 && subtreeSelectedCount < subtreeSelectableIds.length; const userCollapsed = expandedGroups.has(`collapsed-${node.id}`); // Visual expanded state (chevron icon, hover-expand override, drag-collapse override) @@ -3241,7 +3281,24 @@ export function FormGeneratorTable>({ { + if (subtreeSelectableIds.length === 0) return; + const next = new Set(selectedIds); + if (subtreeAllSelected) subtreeSelectableIds.forEach(id => next.delete(id)); + else subtreeSelectableIds.forEach(id => next.add(id)); + _notifySelection(next); + }, + } : undefined} visibleCount={visibleIds.length} isExpanded={isExp} isEditing={editingGroupId === node.id} diff --git a/src/components/FormGenerator/GroupingManager/GroupRow.module.css b/src/components/FormGenerator/GroupingManager/GroupRow.module.css index a00d6e7..376595d 100644 --- a/src/components/FormGenerator/GroupingManager/GroupRow.module.css +++ b/src/components/FormGenerator/GroupingManager/GroupRow.module.css @@ -40,6 +40,17 @@ border-left: 3px solid #d69e2e; } +/* Folder subtree selection (aligned with tbody .tr.selected) */ +.groupFolderRow.folderRowSubtreeFull { + background: rgba(124, 109, 216, 0.08); + background: rgba(var(--color-secondary-rgb), 0.08); +} + +.groupFolderRow.folderRowSubtreePartial { + background: rgba(124, 109, 216, 0.04); + background: rgba(var(--color-secondary-rgb), 0.04); +} + .folderCell { padding: 0 !important; width: 100%; diff --git a/src/components/FormGenerator/GroupingManager/GroupRow.tsx b/src/components/FormGenerator/GroupingManager/GroupRow.tsx index fb97bef..10f77ca 100644 --- a/src/components/FormGenerator/GroupingManager/GroupRow.tsx +++ b/src/components/FormGenerator/GroupingManager/GroupRow.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom'; import { useLanguage } from '../../../providers/language/LanguageContext'; import { useConfirm } from '../../../hooks/useConfirm'; import styles from './GroupRow.module.css'; +import fgTableCss from '../FormGeneratorTable/FormGeneratorTable.module.css'; import type { TableGroupNode } from '../FormGeneratorTable/FormGeneratorTable'; import { FaFolder, FaFolderOpen, FaList, FaPen, FaPlus } from 'react-icons/fa'; @@ -18,14 +19,36 @@ export interface GroupBulkAction { disabled?: boolean; } +/** Horizontal shift per nesting level — keep in sync with item rows (`FormGeneratorTable`). */ +export const GROUP_TREE_INDENT_STEP_PX = 20; + // --------------------------------------------------------------------------- // GroupFolderRow // --------------------------------------------------------------------------- +/** Folder row: optional select column, then one merged cell for folder UI (spans actions + data cols — no blank actions column). */ +export interface GroupFolderTableCells { + showSelect: boolean; + /** `` for folder strip = `detectedColumns.length` + (1 if table has an actions column). */ + dataColumnsCount: number; + selectClassName: string; + selectTdStyle?: React.CSSProperties; +} + interface GroupFolderRowProps { node: TableGroupNode; depth: number; - colSpan: number; + /** Checkbox for “whole subtree”: select / clear all selectable visible items under this folder. */ + subtreeSelect?: { + checked: boolean; + indeterminate: boolean; + disabled: boolean; + onToggle: () => void; + }; + /** When set, use split `` layout; omit single-cell colspan. */ + tableCells?: GroupFolderTableCells; + /** Legacy single spanning cell — only used when `tableCells` is omitted. */ + colSpan?: number; visibleCount: number; isExpanded: boolean; isEditing: boolean; @@ -60,6 +83,8 @@ interface GroupFolderRowProps { export function GroupFolderRow({ node, depth, + subtreeSelect, + tableCells, colSpan, visibleCount, isExpanded, @@ -87,8 +112,15 @@ export function GroupFolderRow({ const { t } = useLanguage(); const { ConfirmDialog } = useConfirm(); const inputRef = useRef(null); + const subtreeCbRef = useRef(null); const totalCount = node.itemIds.length; + useEffect(() => { + const el = subtreeCbRef.current; + if (!el || !subtreeSelect) return; + el.indeterminate = subtreeSelect.indeterminate; + }, [subtreeSelect?.indeterminate, subtreeSelect?.checked, subtreeSelect]); + useEffect(() => { if (isEditing && inputRef.current) { inputRef.current.focus(); @@ -96,26 +128,55 @@ export function GroupFolderRow({ } }, [isEditing]); - const indentPx = depth * 20; + const indentPx = depth * GROUP_TREE_INDENT_STEP_PX; const _rowClass = [ styles.groupFolderRow, + tableCells ? fgTableCss.treeRowIndented : '', isDragOver ? styles.dragOver : '', isDragOverFromGroup ? styles.dragOverGroup : '', isDraggingOut ? styles.draggingOut : '', + subtreeSelect?.checked && !subtreeSelect?.disabled ? styles.folderRowSubtreeFull : '', + subtreeSelect?.indeterminate && !subtreeSelect?.checked ? styles.folderRowSubtreePartial : '', ].filter(Boolean).join(' '); - return ( + const mergedColSpan = + tableCells + ? tableCells.dataColumnsCount + : (colSpan ?? 1); + + const folderStripStyle = + ({ + '--group-indent': `${indentPx}px`, + ...(tableCells + ? { ['--row-tree-indent' as string]: `${depth * GROUP_TREE_INDENT_STEP_PX}px` } + : {}), + }) as React.CSSProperties; + + const guardDragDecor = ( + e: React.DragEvent, + relay: React.DragEventHandler | undefined, + ) => { + const el = e.target as HTMLElement; + if (el.closest('input, button, textarea, label')) { + e.preventDefault(); + e.stopPropagation(); + return; + } + relay?.(e); + }; + + const folderCells = ( <> {typeof document !== 'undefined' && ReactDOM.createPortal(, document.body)} guardDragDecor(e, onGroupDragStart)} + onDrag={(e) => guardDragDecor(e, onGroupDrag)} + onDragEnd={(e) => guardDragDecor(e, onGroupDragEnd)} // item drag-over onDragOver={(e) => { // distinguish item vs group drag via dataTransfer type @@ -135,7 +196,23 @@ export function GroupFolderRow({ onDragLeave={() => { onItemDragLeave(); onGroupDragLeave(); }} onDragEnter={(e) => e.preventDefault()} > - + {tableCells?.showSelect && ( + + {subtreeSelect && ( + { e.stopPropagation(); subtreeSelect.onToggle(); }} + onClick={(e) => e.stopPropagation()} + title={node.name ? t('Auswahl unter „{name}“', { name: node.name }) : t('Auswahl dieser Gruppe')} + aria-label={node.name ? t('Alle sichtbaren Einträge in „{name}“ auswählen', { name: node.name }) : t('Alle sichtbaren Einträge in dieser Gruppe auswählen')} + /> + )} + + )} +
{/* Indent */} {indentPx > 0 && } @@ -143,6 +220,7 @@ export function GroupFolderRow({ {/* Chevron */} - + + )} @@ -224,6 +303,8 @@ export function GroupFolderRow({ ); + + return folderCells; } // --------------------------------------------------------------------------- diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts index b2b59d4..3ffe53b 100644 --- a/src/hooks/useFiles.ts +++ b/src/hooks/useFiles.ts @@ -14,6 +14,7 @@ import { deleteFiles as deleteFilesApi, type FolderInfo, } from '../api/fileApi'; +import type { TableGroupNode } from '../api/connectionApi'; export interface FilePreviewResult { success: boolean; @@ -73,6 +74,7 @@ export function useUserFiles() { totalItems: number; totalPages: number; } | null>(null); + const [groupTree, setGroupTree] = useState([]); const { request, isLoading: loading, error } = useApiRequest(); const { checkPermission } = usePermissions(); @@ -172,6 +174,9 @@ export function useUserFiles() { if (data.pagination) { setPagination(data.pagination); } + if (Array.isArray((data as any).groupTree)) { + setGroupTree((data as any).groupTree); + } } else { // Handle non-paginated response (backward compatibility) console.log('📋 Processing non-paginated response:', { @@ -325,6 +330,7 @@ export function useUserFiles() { attributes, permissions, pagination, + groupTree, fetchFileById, generateEditFieldsFromAttributes, ensureAttributesLoaded diff --git a/src/hooks/usePrompts.ts b/src/hooks/usePrompts.ts index 25c2d55..20870e7 100644 --- a/src/hooks/usePrompts.ts +++ b/src/hooks/usePrompts.ts @@ -13,6 +13,7 @@ import { type AttributeDefinition, type PaginationParams } from '../api/promptApi'; +import type { TableGroupNode } from '../api/connectionApi'; // Re-export types for backward compatibility export type { Prompt, AttributeDefinition, PaginationParams }; @@ -34,6 +35,7 @@ export function usePrompts() { totalItems: number; totalPages: number; } | null>(null); + const [groupTree, setGroupTree] = useState([]); const { request, isLoading: loading, error } = useApiRequest(); const { checkPermission } = usePermissions(); @@ -99,6 +101,9 @@ export function usePrompts() { if (data.pagination) { setPagination(data.pagination); } + if (Array.isArray((data as any).groupTree)) { + setGroupTree((data as any).groupTree); + } } else { // Handle non-paginated response (backward compatibility) const items = Array.isArray(data) ? data : []; @@ -454,10 +459,11 @@ export function usePrompts() { attributes, permissions, pagination, + groupTree, fetchPromptById, generateEditFieldsFromAttributes, generateCreateFieldsFromAttributes, - ensureAttributesLoaded // Generic function to ensure attributes are loaded + ensureAttributesLoaded }; } diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index e7c98d3..2cce1fc 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -57,6 +57,7 @@ export const FilesPage: React.FC = () => { error, refetch: tableRefetch, pagination, + groupTree, fetchFileById, updateFileOptimistically, } = useUserFiles(); @@ -556,7 +557,9 @@ export const FilesPage: React.FC = () => { handleInlineUpdate, updateOptimistically: updateFileOptimistically, previewingFiles, + groupTree, }} + groupingConfig={{ contextKey: 'files', enabled: true }} emptyMessage={emptyTableMessage} />
diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index 3fa1bdf..86eba34 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -34,6 +34,7 @@ export const PromptsPage: React.FC = () => { loading, error, refetch, + groupTree, fetchPromptById, updateOptimistically, } = usePrompts(); @@ -236,7 +237,9 @@ export const PromptsPage: React.FC = () => { handleDelete: handlePromptDelete, handleInlineUpdate, updateOptimistically, + groupTree, }} + groupingConfig={{ contextKey: 'prompts', enabled: true }} emptyMessage={t('Keine Prompts gefunden')} />