diff --git a/src/features/metadata/DynamicCardRenderer.tsx b/src/features/metadata/DynamicCardRenderer.tsx index 71b010b..f8949b5 100644 --- a/src/features/metadata/DynamicCardRenderer.tsx +++ b/src/features/metadata/DynamicCardRenderer.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { EntityMetadata, FieldDefinition, @@ -57,7 +57,7 @@ export interface DynamicCardRendererProps { export const DynamicCardRenderer: React.FC = ({ entity, record, - layout: _layout, + layout, onEdit, onDelete, onClose, @@ -67,6 +67,16 @@ export const DynamicCardRenderer: React.FC = ({ const [auditLogs, setAuditLogs] = useState([]); const [copiedJson, setCopiedJson] = useState(false); + // Sorted and enabled card blocks from layout config + const cardBlocks = useMemo(() => { + if (layout?.cardBlocks && layout.cardBlocks.length > 0) { + return [...layout.cardBlocks] + .filter((b) => b.enabled !== false) + .sort((a, b) => a.order - b.order); + } + return []; + }, [layout]); + // Fetch audit logs for this specific record useEffect(() => { async function loadAudit() { @@ -435,27 +445,177 @@ export const DynamicCardRenderer: React.FC = ({ {/* Tab: Overview */} {activeTab === 'overview' && (
-
-

- Все атрибуты записи -

- -
- {entity.fields.map((f) => ( -
- - {f.label} - -
- {renderFieldValue(f)} + {cardBlocks.length > 0 ? ( + cardBlocks.map((block) => { + if (block.type === 'header') { + return ( +
+
+ {block.settings?.showAvatar !== false && ( +
+ {recordTitle.slice(0, 1).toUpperCase()} +
+ )} +
+

{recordTitle}

+

ID: {record.id}

+
+
-
- ))} + ); + } + + if (block.type === 'badges') { + return ( +
+ + {entity.code} + + {record.status !== undefined && record.status !== null && ( + + {String(record.status)} + + )} +
+ ); + } + + if (block.type === 'fields_grid') { + const specifiedKeys = block.settings?.fieldKeys; + const targetFields = entity.fields + .filter((f) => !f.hidden) + .filter( + (f) => + !specifiedKeys || specifiedKeys.length === 0 || specifiedKeys.includes(f.key) + ); + + const colsCount = block.settings?.columnsCount || 2; + const gridClass = + colsCount === 1 + ? 'grid-cols-1' + : colsCount === 3 + ? 'grid-cols-1 sm:grid-cols-3' + : 'grid-cols-1 sm:grid-cols-2'; + + return ( +
+

+ {block.title || 'Атрибуты'} +

+
+ {targetFields.map((f) => ( +
+ + {f.label} + +
+ {renderFieldValue(f)} +
+
+ ))} +
+
+ ); + } + + if (block.type === 'timeline') { + return ( +
+

+ {block.title || 'Лента активности'} +

+
+ {auditLogs.slice(0, block.settings?.maxItems || 5).map((log) => ( +
+
+ {log.summary} + + {log.action} • {log.userName || log.userId} + +
+ + {formatDateTime(log.timestamp)} + +
+ ))} + {auditLogs.length === 0 && ( +
+ Записей активности нет. +
+ )} +
+
+ ); + } + + if (block.type === 'tasks_notes') { + return ( +
+

+ {block.title || 'Заметки и задачи'} +

+
+ Связанные задачи отсутствуют +
+
+ ); + } + + return null; + }) + ) : ( +
+

+ Все атрибуты записи +

+ +
+ {entity.fields + .filter((f) => !f.hidden) + .map((f) => ( +
+ + {f.label} + +
+ {renderFieldValue(f)} +
+
+ ))} +
-
+ )} {/* System Audit Information */}
diff --git a/src/features/metadata/DynamicFormRenderer.tsx b/src/features/metadata/DynamicFormRenderer.tsx index 954cb36..3403295 100644 --- a/src/features/metadata/DynamicFormRenderer.tsx +++ b/src/features/metadata/DynamicFormRenderer.tsx @@ -20,6 +20,7 @@ import { } from '../../shared/lib/fieldEngine'; import { ulid } from '../../shared/lib/ulid'; import { useToast } from '../../shared/ui/useToast'; +import { useSessionStore } from '../../core/session/sessionStore'; import { AlertCircle, Star, @@ -151,25 +152,74 @@ export const DynamicFormRenderer: React.FC = ({ ]; }, [layout, entity]); - // Check conditional visibility - const isFieldVisible = useCallback((field: FieldDefinition): boolean => { - if (field.hidden) return false; - if (!field.conditionalVisibility || !field.conditionalVisibility.fieldKey) return true; + const currentRoleId = useSessionStore((s) => s.currentRoleId); - const { fieldKey, operator, value } = field.conditionalVisibility; - const currentVal = formValues[fieldKey]; + // Check conditional visibility and role permissions + const isFieldVisible = useCallback( + (field: FieldDefinition): boolean => { + if (field.hidden) return false; - switch (operator) { - case 'eq': - return String(currentVal ?? '') === String(value ?? ''); - case 'neq': - return String(currentVal ?? '') !== String(value ?? ''); - case 'contains': - return String(currentVal ?? '').includes(String(value ?? '')); - default: - return true; - } - }, [formValues]); + // Role permission / visibility check + if (currentRoleId !== 'owner') { + const customRoleVisibility = (field as unknown as { roleVisibility?: string[] }).roleVisibility; + const viewRoles = field.permissions?.viewRoles || customRoleVisibility; + if (viewRoles && viewRoles.length > 0 && !viewRoles.includes(currentRoleId)) { + return false; + } + } + + // Check conditionalRules array or single conditionalVisibility + const customRules = (field as unknown as { conditionalRules?: typeof field.conditionalVisibility[] }).conditionalRules; + const rules = (customRules && customRules.length > 0) + ? customRules + : (field.conditionalVisibility ? [field.conditionalVisibility] : []); + + for (const rule of rules) { + if (!rule || !rule.fieldKey) continue; + const { fieldKey, operator, value } = rule; + const currentVal = formValues[fieldKey]; + + switch (operator) { + case 'eq': + if (String(currentVal ?? '') !== String(value ?? '')) return false; + break; + case 'neq': + if (String(currentVal ?? '') === String(value ?? '')) return false; + break; + case 'empty': + if ( + currentVal !== undefined && + currentVal !== null && + currentVal !== '' && + (!Array.isArray(currentVal) || currentVal.length > 0) + ) { + return false; + } + break; + case 'not_empty': + if ( + currentVal === undefined || + currentVal === null || + currentVal === '' || + (Array.isArray(currentVal) && currentVal.length === 0) + ) { + return false; + } + break; + case 'contains': + if (!String(currentVal ?? '').toLowerCase().includes(String(value ?? '').toLowerCase())) { + return false; + } + break; + default: + break; + } + } + + return true; + }, + [formValues, currentRoleId] + ); const handleFieldChange = (key: string, val: unknown) => { setFormValues((prev) => ({ ...prev, [key]: val })); diff --git a/src/features/metadata/DynamicTable.tsx b/src/features/metadata/DynamicTable.tsx index 7b96eb5..0b3fce7 100644 --- a/src/features/metadata/DynamicTable.tsx +++ b/src/features/metadata/DynamicTable.tsx @@ -88,6 +88,40 @@ export const DynamicTable: React.FC = ({ }); } + // View saved filters + if (view?.filters && view.filters.length > 0) { + const conjunction = view.filterConjunction || 'AND'; + result = result.filter((row) => { + const matches = view.filters.map((filter) => { + const val = row[filter.field]; + switch (filter.operator) { + case 'eq': + return String(val ?? '') === String(filter.value ?? ''); + case 'neq': + return String(val ?? '') !== String(filter.value ?? ''); + case 'contains': + return String(val ?? '').toLowerCase().includes(String(filter.value ?? '').toLowerCase()); + case 'gt': + return Number(val) > Number(filter.value); + case 'lt': + return Number(val) < Number(filter.value); + case 'in': + return Array.isArray(filter.value) && filter.value.includes(val); + case 'is_empty': + return val === undefined || val === null || val === ''; + case 'is_not_empty': + return val !== undefined && val !== null && val !== ''; + default: + return true; + } + }); + + return conjunction === 'OR' + ? matches.some(Boolean) + : matches.every(Boolean); + }); + } + // Sort if (sortField) { result.sort((a, b) => { @@ -102,7 +136,7 @@ export const DynamicTable: React.FC = ({ } return result; - }, [records, searchQuery, sortField, sortOrder]); + }, [records, searchQuery, sortField, sortOrder, view]); // Paginated records const totalPages = Math.ceil(filteredRecords.length / pageSize) || 1; @@ -237,10 +271,16 @@ export const DynamicTable: React.FC = ({ {columns.map((col) => { const isSorted = sortField === col.key; + const customWidth = view?.columnWidths?.[col.key]; + const colStyle = customWidth + ? { width: `${customWidth}px`, minWidth: `${customWidth}px` } + : undefined; + return ( handleHeaderClick(col.key)} + style={colStyle} className="p-3 font-semibold text-krona-primary hover:bg-krona-surface-muted/60 cursor-pointer transition-colors whitespace-nowrap" >
@@ -272,9 +312,13 @@ export const DynamicTable: React.FC = ({ {columns.map((col) => { const val = row[col.key]; const display = formatFieldValue(col.type, val, col); + const customWidth = view?.columnWidths?.[col.key]; + const colStyle = customWidth + ? { width: `${customWidth}px`, minWidth: `${customWidth}px` } + : undefined; return ( - + {col.type === 'color' && val ? (
{ + beforeEach(() => { + useSessionStore.getState().setCurrentRoleId('owner'); + }); + + const mockEntity: EntityMetadata = { + id: 'ent_client_contract', + code: 'client_contract', + name: 'Договор с клиентом', + pluralName: 'Договоры с клиентами', + isSystem: false, + category: 'custom', + maturity: 'target_prototype', + fields: [ + { + id: 'f_number', + key: 'contractNumber', + label: 'Номер договора', + type: 'text', + required: true, + }, + { + id: 'f_type', + key: 'contractType', + label: 'Тип договора', + type: 'select', + options: [ + { value: 'standard', label: 'Стандартный' }, + { value: 'nda', label: 'NDA' }, + ], + }, + { + id: 'f_secret_clause', + key: 'secretClause', + label: 'Секретное условие NDA', + type: 'text', + conditionalVisibility: { + fieldKey: 'contractType', + operator: 'eq', + value: 'nda', + }, + }, + { + id: 'f_owner_budget', + key: 'ownerBudget', + label: 'Бюджет сделки (Собственник)', + type: 'money', + permissions: { + viewRoles: ['owner'], + }, + }, + { + id: 'f_hidden_flag', + key: 'internalAuditToken', + label: 'Служебный токен аудита', + type: 'text', + hidden: true, + }, + ], + }; + + describe('Form Builder Runtime (F12)', () => { + it('1. Conditional visibility and role visibility in DynamicFormRenderer', async () => { + // Step A: Under 'owner' role, secretClause is hidden initially because contractType is empty + const { rerender } = render( + {}} + /> + ); + + expect(screen.getByText('Номер договора')).toBeDefined(); + expect(screen.getByText('Тип договора')).toBeDefined(); + expect(screen.queryByText('Секретное условие NDA')).toBeNull(); // Condition not met + expect(screen.getByText('Бюджет сделки (Собственник)')).toBeDefined(); // Owner can see + expect(screen.queryByText('Служебный токен аудита')).toBeNull(); // Hidden field is never rendered + + // Step B: Switch role to 'cashier' + act(() => { + useSessionStore.getState().setCurrentRoleId('cashier'); + }); + + rerender( + {}} + /> + ); + + // Cashier cannot see ownerBudget + expect(screen.queryByText('Бюджет сделки (Собственник)')).toBeNull(); + }); + + it('2. useFormBuilderHistory provides robust 50-step undo/redo capacity', () => { + const { result } = renderHook(() => useFormBuilderHistory()); + + const initialLayout: LayoutDefinition = { + id: 'lay_test', + entityCode: 'client_contract', + name: 'Базовый макет', + isDefault: true, + sections: [], + }; + + act(() => { + result.current.initHistory(initialLayout); + }); + + expect(result.current.canUndo).toBe(false); + expect(result.current.canRedo).toBe(false); + + // Push 55 mutations + for (let i = 1; i <= 55; i++) { + act(() => { + result.current.pushState({ + ...initialLayout, + name: `Редакция ${i}`, + }); + }); + } + + // History should be capped at 50 + expect(result.current.history.length).toBeLessThanOrEqual(50); + expect(result.current.canUndo).toBe(true); + + // Undo once + act(() => { + result.current.handleUndo(); + }); + expect(result.current.canRedo).toBe(true); + expect(result.current.currentLayout?.name).toBe('Редакция 54'); + + // Redo once + act(() => { + result.current.handleRedo(); + }); + expect(result.current.currentLayout?.name).toBe('Редакция 55'); + }); + }); + + describe('Card Builder Runtime (F13)', () => { + it('3. DynamicCardRenderer strictly executes cardBlocks in defined order and excludes hidden fields', () => { + const sampleRecord = { + id: 'rec_01', + entityType: 'client_contract', + contractNumber: 'DOG-2026-001', + contractType: 'standard', + secretClause: 'Secret text', + ownerBudget: { minor: 50000000, currency: 'RUB' }, + internalAuditToken: 'SECRET_TOKEN_123', + }; + + const cardLayout: LayoutDefinition = { + id: 'card_lay_01', + entityCode: 'client_contract', + name: 'Макет карточки', + isDefault: true, + sections: [], + cardBlocks: [ + { + id: 'blk_badges', + type: 'badges', + title: 'Бейджи', + enabled: true, + columnSpan: 3, + order: 1, + }, + { + id: 'blk_header', + type: 'header', + title: 'Шапка', + enabled: true, + columnSpan: 3, + order: 2, + }, + { + id: 'blk_fields', + type: 'fields_grid', + title: 'Атрибуты', + enabled: true, + columnSpan: 2, + order: 3, + settings: { + fieldKeys: ['contractNumber', 'internalAuditToken'], + columnsCount: 2, + }, + }, + ], + }; + + render( + + ); + + // Verify blocks rendered in DOM + expect(screen.getByTestId('card-block-badges')).toBeDefined(); + expect(screen.getByTestId('card-block-header')).toBeDefined(); + expect(screen.getByTestId('card-block-fields_grid')).toBeDefined(); + + // Field contractNumber is visible + expect(screen.getByTestId('card-field-contractNumber')).toBeDefined(); + + // internalAuditToken is marked hidden in entity metadata, must NOT be rendered even if in fieldKeys! + expect(screen.queryByTestId('card-field-internalAuditToken')).toBeNull(); + }); + }); + + describe('View Builder Runtime (F14)', () => { + it('4. DynamicTable filters records by saved view filters and applies column widths', () => { + const records = [ + { + id: 'rec_1', + entityType: 'client_contract', + contractNumber: 'DOG-001', + contractType: 'standard', + }, + { + id: 'rec_2', + entityType: 'client_contract', + contractNumber: 'DOG-002', + contractType: 'nda', + }, + { + id: 'rec_3', + entityType: 'client_contract', + contractNumber: 'DOG-003', + contractType: 'nda', + }, + ]; + + const customView: ViewDefinition = { + id: 'view_nda_only', + entityCode: 'client_contract', + name: 'Только NDA', + type: 'table', + isDefault: true, + visibleFieldKeys: ['contractNumber', 'contractType'], + filters: [ + { + field: 'contractType', + operator: 'eq', + value: 'nda', + }, + ], + columnWidths: { + contractNumber: 220, + contractType: 140, + }, + }; + + render( + + ); + + // Only DOG-002 and DOG-003 should be displayed + expect(screen.queryByText('DOG-001')).toBeNull(); + expect(screen.getAllByText('DOG-002').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('DOG-003').length).toBeGreaterThanOrEqual(1); + + // Found count should be 2 + expect(screen.getByText('2')).toBeDefined(); + }); + }); +});