diff --git a/client/src/components/CreateTaskFormContent.tsx b/client/src/components/CreateTaskFormContent.tsx index a185925..13ae64c 100644 --- a/client/src/components/CreateTaskFormContent.tsx +++ b/client/src/components/CreateTaskFormContent.tsx @@ -34,6 +34,7 @@ import { } from '@/components/task-form-fields'; import { useAuth } from '@/hooks/useAuth'; import { isAdmin } from '@/lib/permissions'; +import { useSkinComponents } from '@/themes/ThemeProvider'; import { computeFieldState, buildRequiredConditionContext, @@ -95,6 +96,8 @@ export function CreateTaskFormContent({ open = true, }: CreateTaskFormContentProps) { const { toast } = useToast(); + // Тема 2.0: колонки формы и отступы сетки из вариантов скина + const { form: formComponents } = useSkinComponents(); const { user: currentUser } = useAuth(); const hasAppliedPrefillRef = useRef(false); const wasOpenRef = useRef(false); @@ -374,8 +377,14 @@ export function CreateTaskFormContent({
- {/* Сетка полей в стиле NocoBase/antd: на десктопе 2 колонки, широкие поля — на всю строку */} - createTaskMutation.mutate(data))} className="grid gap-x-4 gap-y-3 md:grid-cols-2"> + {/* Сетка полей в стиле NocoBase/antd: колонки и отступы — из Темы 2.0 (дефолт: 2 колонки на десктопе) */} + createTaskMutation.mutate(data))} + className={cn( + 'grid gap-x-[var(--field-gap-x,1rem)] gap-y-[var(--field-gap-y,0.75rem)]', + formComponents.columns === 2 && 'md:grid-cols-2', + )} + > {!formInfo?.hiddenSystemFields?.includes('assignedTo') && ( /* Ответственный */ diff --git a/client/src/components/TaskRegularTable.tsx b/client/src/components/TaskRegularTable.tsx index 48065a4..2d8eeac 100644 --- a/client/src/components/TaskRegularTable.tsx +++ b/client/src/components/TaskRegularTable.tsx @@ -340,7 +340,7 @@ export default function TaskRegularTable({ taskId, tabId, columns, readOnly = fa const resolvedFormulaValues = allResolvedRowValues[rowIndex]; return ( - + {rowIndex + 1} @@ -478,7 +478,7 @@ export default function TaskRegularTable({ taskId, tabId, columns, readOnly = fa ); })} {localPendingRows.map((row, idx) => ( - + {rows.length + idx + 1} diff --git a/client/src/components/fields/field-styles.ts b/client/src/components/fields/field-styles.ts index 5f4ae6b..1aa1855 100644 --- a/client/src/components/fields/field-styles.ts +++ b/client/src/components/fields/field-styles.ts @@ -1,18 +1,23 @@ import { cn } from '@/lib/utils'; // Единый стиль контрола поля (референс NocoBase/antd): один визуал во всех контекстах -// size: form — форма создания (h-8 w-full), detail — карточка задачи (h-7 w-auto), cell — ячейка таблицы (h-7 border-0) +// size: form — форма создания, detail — карточка задачи, cell — ячейка таблицы +// Тема 2.0: высоты/паддинги/фон/бордер/радиус читаются из CSS-переменных скина +// (--control-height-*, --control-px, --control-bg, --control-border-*, --control-radius); +// fallback'и в var() повторяют прежний вид, поэтому без Темы 2.0 ничего не меняется. export type FieldControlSize = 'form' | 'detail' | 'cell'; -// Базовые классы контрола: рамка, фон, типографика и состояния hover/focus +// Базовые классы контрола: рамка, фон, типографика и состояния hover/focus. +// border-width/цвет/фон/радиус — через var-ы Темы 2.0 (outline — дефолт); +// в ячейке (cell) они перекрываются border-0/bg-transparent через twMerge. const FIELD_CONTROL_BASE = - 'rounded-md border border-input bg-background px-2.5 text-base md:text-[13px] transition-[color,background-color,border-color,box-shadow] hover:border-primary/50 focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-primary'; + 'rounded-[var(--control-radius,calc(var(--radius)_-_2px))] border-[length:var(--control-border-width,1px)] border-[color:var(--control-border-color,var(--input))] bg-[color:var(--control-bg,var(--background))] px-[var(--control-px,10px)] text-base md:text-[13px] transition-[color,background-color,border-color,box-shadow] hover:border-primary/50 focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-primary'; // Классы по контексту использования const FIELD_CONTROL_SIZE: Record = { - form: 'h-8 w-full', - detail: 'h-7 w-auto max-w-[420px]', - cell: 'h-7 w-full border-0 bg-transparent shadow-none hover:border-transparent focus:ring-1', + form: 'h-[var(--control-height-form,32px)] w-full', + detail: 'h-[var(--control-height-detail,28px)] w-auto max-w-[420px]', + cell: 'h-[var(--control-height-cell,28px)] w-full border-0 bg-transparent shadow-none hover:border-transparent focus:ring-1', }; // Единый класс контрола поля. Дополнительные классы передаются вторым аргументом. @@ -21,7 +26,9 @@ export function fieldControlClass(size: FieldControlSize = 'form', className?: s } // Единый стиль label поля формы (вертикальная раскладка, как в NocoBase/antd) -export const fieldLabelClass = 'text-[13px] font-medium text-muted-foreground'; +// Тема 2.0: регистр и жирность — через --label-casing / --label-weight +export const fieldLabelClass = + 'text-[13px] [font-weight:var(--label-weight,500)] [text-transform:var(--label-casing,none)] text-muted-foreground'; // Единый стиль сообщения об ошибке под полем export const fieldErrorClass = 'text-xs text-destructive'; diff --git a/client/src/components/tabs/JsComponentRenderer.tsx b/client/src/components/tabs/JsComponentRenderer.tsx index 2bfa625..a77ebde 100644 --- a/client/src/components/tabs/JsComponentRenderer.tsx +++ b/client/src/components/tabs/JsComponentRenderer.tsx @@ -26,6 +26,7 @@ import { useToast } from '@/hooks/use-toast'; import { SafeHtmlRenderer } from '@/components/SafeHtmlRenderer'; import { stripHtml } from '@/lib/htmlUtils'; import { useEvents } from '@/hooks/useEvents'; +import TaskChat from '@/components/TaskChat'; export interface JsComponentRendererProps { code: string; @@ -105,6 +106,8 @@ interface CtxObject { ui: CtxUiLib; SafeHtml: typeof SafeHtmlRenderer; useEvents: typeof useEvents; + // Чат задачи — чтобы кастомные layout'ы могли встроить его (пропсы: { taskId: number }) + TaskChat: typeof TaskChat; }; request(opts: CtxRequestOptions): Promise; navigate(path: string): void; @@ -267,7 +270,7 @@ function buildCtx( }; return { - libs: { React: ReactLib, ui, SafeHtml: SafeHtmlRenderer, useEvents }, + libs: { React: ReactLib, ui, SafeHtml: SafeHtmlRenderer, useEvents, TaskChat }, request: async ({ url, method = 'GET', data }: CtxRequestOptions): Promise => { const res = await apiRequest(method.toUpperCase(), url, data); return res.json(); diff --git a/client/src/components/task-fields/FieldValueRow.tsx b/client/src/components/task-fields/FieldValueRow.tsx new file mode 100644 index 0000000..c99779f --- /dev/null +++ b/client/src/components/task-fields/FieldValueRow.tsx @@ -0,0 +1,1605 @@ +import { useState, useEffect, useRef, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Link } from 'wouter'; +import { Button } from '@/components/ui/button'; +import { CreateTaskDialog } from '@/components/CreateTaskDialog'; +import { AutoResizeTextarea } from '@/components/ui/auto-resize-textarea'; +import { Link2, Table, ExternalLink, PlusCircle, FileText, Upload, X, Download, Clock, Pencil, PencilOff, MapPin, Building2 } from 'lucide-react'; +import { apiRequest, queryClient, OfflineQueuedError } from '@/lib/queryClient'; +import { useToast } from '@/hooks/use-toast'; +import { cn } from '@/lib/utils'; +import { addFileToken, openFilePresigned } from '@/lib/fileUtils'; +import { normalizeSelectOptions, getSelectOptionLabel } from '@/lib/fieldOptions'; +import { SearchableMultiSelect } from '@/components/fields/SearchableMultiSelect'; +import { fieldControlClass, fieldLabelClass } from '@/components/fields/field-styles'; +import { FileViewerModal, detectViewerType } from '@/components/FileViewerModal'; +import { SafeHtmlRenderer } from '@/components/SafeHtmlRenderer'; +import GeoField, { type GeoValue } from '@/components/GeoField'; +import GeoNavigationPicker from '@/components/GeoNavigationPicker'; +import { useOfflineSyncContext } from '@/contexts/OfflineSyncContext'; +import { saveTask, getTask, getDirectory, saveDirectory } from '@/lib/tasksCache'; +import { FORMULA_ERROR, FORMULA_CIRCULAR_ERROR } from '@/lib/formulaEngine'; +import CompanyFieldInput, { type DadataCompany } from '@/components/CompanyFieldInput'; +import ContractNumberField from '@/components/ContractNumberField'; +import ContractNumberDisplay from '@/components/ContractNumberDisplay'; +import TableFieldSelect from '@/components/TableFieldSelect'; +import TableFieldMultiSelect from '@/components/TableFieldMultiSelect'; +import TableFieldWithSubdirectory from '@/components/TableFieldWithSubdirectory'; +import ContactField from '@/components/ContactField'; +import { UserRoleRenderer, UserRoleEditor } from '@/components/fields'; +import { TaskFieldSelect } from '@/components/task-form-fields'; +import { DocumentTemplateField } from '@/components/fields/DocumentTemplateField'; +import { useSkinComponents } from '@/themes/ThemeProvider'; +import { + isFieldValueEmpty, + computeFieldState, + buildRequiredConditionContext, + buildClientFieldValues, + getAllConditionFields, + type ClientFormField, +} from '@/lib/fieldConditions'; + +// ── Типы полей/задачи (вынесены из TaskDetail, используются также пресетами представления) ── + +export interface FormFieldData { + id: number; + name: string; + code: string; + type: string; + isRequired: boolean; + placeholder?: string; + options?: unknown; + defaultValue?: string; + validationRules?: any; + requiredRules?: { + enabled?: boolean; + statusId?: number | "__creation__" | null; + logic?: "and" | "or"; + conditions?: Array<{ fieldCode: string; operator?: "contains" | "empty" | "filled"; value?: string }>; + } | null; + immutableRules?: { + enabled?: boolean; + statusId?: number | "__creation__" | null; + logic?: "and" | "or"; + conditions?: Array<{ fieldCode: string; operator?: "contains" | "empty" | "filled"; value?: string }>; + } | null; + visibilityRules?: { + enabled?: boolean; + statusId?: number | "__creation__" | null; + logic?: "and" | "or"; + conditions?: Array<{ fieldCode: string; operator?: "contains" | "empty" | "filled"; value?: string }>; + } | null; + tabId?: number | null; + linkedFormId?: number | null; + taskRelationType?: 'parent' | 'child' | null; + buttonActionType?: 'open_url' | 'create_task' | 'generate_document' | null; + buttonUrl?: string | null; + buttonFormId?: number | null; + companyAutofill?: Record; + maxFileCount?: number | null; + maxFileSizeMB?: number | null; + autoProlongation?: boolean; + autoProlongationLabel?: string; + allowMultiple?: boolean; +} + +export interface TaskData { + id: number; + title: string; + description?: string; + currentStatusId: number; + assignedTo?: number; + createdBy: number; + createdAt: string; + updatedAt: string; + completedAt?: string; + isCompleted?: boolean; + dueDate?: string; + parentTaskId?: number | null; + depth?: number; +} + +export interface TaskFieldValueData { + id: number; + taskId: number; + fieldId: number; + value: string | number | boolean | null; +} + +// Минимальная форма кэша детали задачи для optimistic-обновления значений полей +interface TaskDetailCacheShape { + fieldValues?: TaskFieldValueData[]; +} + +// Компонент для отображения значения связанной задачи +function LinkedTaskDisplay({ linkedFormId, taskId, relationType }: { linkedFormId: number; taskId: string | number; relationType?: 'parent' | 'child' | null }) { + const { isOnline } = useOfflineSyncContext(); + const [offlineTask, setOfflineTask] = useState<{ id: number; title: string } | null>(null); + + const { data: tasksData, isLoading } = useQuery<{ tasks: Array<{ id: number; title: string }> }>({ + queryKey: ['/api/forms', linkedFormId, 'tasks'], + enabled: !!linkedFormId && !!taskId && isOnline, + staleTime: 5 * 60 * 1000, + }); + + // Offline fallback: resolve task title from IDB task-cache + useEffect(() => { + const numId = Number(taskId); + if (!isOnline && !tasksData && numId) { + getTask(numId).then(cached => { + if (cached?.data && typeof cached.data === 'object') { + const d = cached.data as { task?: { id: number; title: string } }; + if (d.task) setOfflineTask({ id: d.task.id, title: d.task.title }); + } + }); + } + if (isOnline) setOfflineTask(null); + }, [isOnline, tasksData, taskId]); + + if (isLoading && isOnline) return Загрузка...; + + const liveTask = tasksData?.tasks?.find(t => t.id.toString() === taskId?.toString()); + const task = liveTask ?? offlineTask; + if (!task) return Задача не найдена; + + return ( + + + + + {task.title} + + + {relationType === 'parent' && ( + + Родитель + + )} + {relationType === 'child' && ( + + Дочерняя + + )} + + ); +} + +// Компонент для отображения значения из справочника +type DirectoryData = { table: { rows: Array<{ id: number; values: string[] }>; columns: Array<{ name: string }> } }; + +function TableValueDisplay({ tableId, displayColumn, rowId }: { tableId: number; displayColumn?: string; rowId: string | number | Array }) { + const { isOnline } = useOfflineSyncContext(); + const [offlineData, setOfflineData] = useState(null); + + const isArray = Array.isArray(rowId); + const rowIds = isArray ? rowId.map(String) : [String(rowId)]; + + const { data: tableData, isLoading } = useQuery({ + queryKey: ['/api/directories', tableId], + enabled: !!tableId && rowIds.length > 0 && isOnline, + staleTime: 5 * 60 * 1000, + }); + + // Persist to IDB whenever live data arrives + useEffect(() => { + if (tableData && tableId) { + saveDirectory(tableId, tableData).catch(() => {}); + } + }, [tableData, tableId]); + + // Load from IDB when offline and live data is unavailable + useEffect(() => { + if (!isOnline && !tableData && tableId && rowIds.length > 0) { + getDirectory(tableId).then(cached => { + if (cached) setOfflineData(cached.data as DirectoryData); + }).catch(() => {}); + } + }, [isOnline, tableData, tableId, rowIds]); + + const effectiveData = tableData ?? offlineData; + + if (isLoading && !offlineData) return Загрузка...; + + const table = effectiveData?.table; + if (!table) return Таблица не найдена; + + let displayColumnIndex = table.columns?.findIndex(c => c.name === displayColumn); + if (displayColumnIndex === undefined || displayColumnIndex < 0) { + displayColumnIndex = 0; + } + + const rows = table.rows || []; + const labels = rowIds + .map((id) => { + const row = rows.find((r) => r.id.toString() === id); + if (!row) return null; + return row.values[displayColumnIndex] || row.values[0] || `#${id}`; + }) + .filter(Boolean); + + if (labels.length === 0) return Запись не найдена; + + return ( + + + {labels.join(', ')} + + ); +} + +// Компонент для рендеринга кнопки с действием +function ButtonField({ + field, + taskId, + allFieldValues, + allFields, +}: { + field: FormFieldData; + taskId?: number; + allFieldValues?: Record; + allFields?: FormFieldData[]; +}) { + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const { toast } = useToast(); + + const buttonOptions = (field.options || {}) as { + documentTemplateId?: number; + generateFormat?: 'docx' | 'pdf' | 'both'; + generateReturnTo?: string; + relationType?: 'none' | 'parent' | 'child'; + copyFields?: Array<{ sourceFieldId: number; targetFieldId: number }>; + }; + + const buildInitialValues = (): Record | undefined => { + const copyFields = buttonOptions.copyFields; + if (!copyFields || copyFields.length === 0 || !allFieldValues || !allFields) return undefined; + + const fieldById = new Map(allFields.map(f => [f.id, f])); + const initialValues: Record = {}; + + for (const mapping of copyFields) { + const sourceField = fieldById.get(mapping.sourceFieldId); + if (!sourceField) continue; + const value = allFieldValues[sourceField.code]; + if (value === undefined || value === null || value === '') continue; + initialValues[`customField_${mapping.targetFieldId}`] = value; + } + + return Object.keys(initialValues).length > 0 ? initialValues : undefined; + }; + + const handleCreated = async (createdTask: { id: number; title: string }) => { + const relationType = buttonOptions.relationType; + if (!taskId || !relationType || relationType === 'none') return; + + try { + if (relationType === 'parent') { + // Новая задача — родительская для текущей + await apiRequest('POST', `/api/tasks/${createdTask.id}/relations`, { + relatedTaskId: taskId, + type: 'child', + }); + } else if (relationType === 'child') { + // Новая задача — дочерняя для текущей + await apiRequest('POST', `/api/tasks/${taskId}/relations`, { + relatedTaskId: createdTask.id, + type: 'child', + }); + } + queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'related'] }); + queryClient.invalidateQueries({ queryKey: ['/api/tasks', createdTask.id, 'related'] }); + } catch (error: any) { + toast({ + title: 'Связь не создана', + description: error.message || 'Не удалось связать задачи', + variant: 'destructive', + }); + } + }; + + const handleButtonClick = () => { + if (field.buttonActionType === 'open_url' && field.buttonUrl) { + window.open(field.buttonUrl, '_blank', 'noopener,noreferrer'); + } else if (field.buttonActionType === 'create_task' && field.buttonFormId) { + setCreateDialogOpen(true); + } else if (field.buttonActionType === 'generate_document' && taskId) { + const templateId = buttonOptions.documentTemplateId; + if (!templateId) return; + const format = buttonOptions.generateFormat || 'both'; + const returnTo = buttonOptions.generateReturnTo || window.location.href; + const url = `/api/documents/generate-link?templateId=${templateId}&taskId=${taskId}&format=${format}&returnTo=${encodeURIComponent(returnTo)}`; + window.location.href = url; + } + }; + + const isConfigured = field.buttonActionType && ( + (field.buttonActionType === 'open_url' && field.buttonUrl) || + (field.buttonActionType === 'create_task' && field.buttonFormId) || + (field.buttonActionType === 'generate_document' && taskId && buttonOptions.documentTemplateId) + ); + + return ( +
+ + {field.buttonActionType === 'create_task' && field.buttonFormId && ( + + )} +
+ ); +} + +// Типы полей, поддерживающие inline-редактирование +const INLINE_EDITABLE_TYPES = new Set(['text', 'number', 'textarea', 'rich-text', 'date', 'datetime', 'select', 'radio-group', 'checkbox', 'toggle', 'user', 'file', 'company', 'geo', 'contract-number', 'table', 'checklist', 'contact', 'task']); + +export interface InlineUserOption { + id: number; + fullName?: string | null; + firstName?: string | null; + lastName?: string | null; + middleName?: string | null; + email: string; +} + +// Вспомогательная функция: форматирование размера файла +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} Б`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`; + return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`; +} + +type FileInfo = { url: string; name: string; size?: number }; + +// Безопасное преобразование значения в строку (защита от null/undefined/объектов) +function safeString(value: unknown): string { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +// Парсим значение поля «файл» в массив FileInfo с поддержкой старых форматов +function parseFileValue(initialValue: unknown): FileInfo[] { + if (!initialValue) return []; + + // Новый формат: массив объектов + if (Array.isArray(initialValue)) { + return (initialValue as unknown[]).flatMap((item) => { + if (item && typeof item === 'object') { + const obj = item as Record; + if (typeof obj.url === 'string') { + return [{ url: obj.url, name: String(obj.name || obj.url), size: typeof obj.size === 'number' ? obj.size : undefined }]; + } + } + return []; + }); + } + + // Старый формат: одиночный объект + if (typeof initialValue === 'object') { + const obj = initialValue as Record; + if (typeof obj.url === 'string') { + return [{ url: obj.url, name: String(obj.name || obj.url), size: typeof obj.size === 'number' ? obj.size : undefined }]; + } + return []; + } + + // Строка (очень старый формат или JSON) + if (typeof initialValue === 'string' && initialValue) { + try { + const parsed: unknown = JSON.parse(initialValue); + return parseFileValue(parsed); + } catch { + return [{ url: initialValue, name: initialValue }]; + } + } + + return []; +} + +// Компонент для поля типа «файл» с inline-загрузкой (поддержка нескольких файлов) +function FileFieldRow({ + field, + value: initialValue, + taskId, + formId, +}: { + field: FormFieldData; + value: unknown; + taskId: number; + formId?: number; +}) { + const { toast } = useToast(); + const fileInputRef = useRef(null); + const [isUploading, setIsUploading] = useState(false); + + const files = parseFileValue(initialValue); + const [viewerFile, setViewerFile] = useState<{ url: string; name: string } | null>(null); + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (field.maxFileCount != null && files.length >= field.maxFileCount) { + toast({ + title: 'Превышен лимит файлов', + description: `Максимальное количество файлов для этого поля: ${field.maxFileCount}`, + variant: 'destructive', + }); + if (fileInputRef.current) fileInputRef.current.value = ''; + return; + } + + if (field.maxFileSizeMB != null) { + const currentTotalBytes = files.reduce((sum, f) => sum + (f.size || 0), 0); + const limitBytes = field.maxFileSizeMB * 1024 * 1024; + if (currentTotalBytes + file.size > limitBytes) { + toast({ + title: 'Превышен лимит размера', + description: `Суммарный размер файлов не должен превышать ${field.maxFileSizeMB} МБ`, + variant: 'destructive', + }); + if (fileInputRef.current) fileInputRef.current.value = ''; + return; + } + } + + setIsUploading(true); + try { + const formData = new FormData(); + formData.append('file', file); + + const uploadUrl = `/api/upload?fieldId=${field.id}&taskId=${taskId}`; + const uploadRes = await fetch(uploadUrl, { + method: 'POST', + credentials: 'include', + body: formData, + }); + if (!uploadRes.ok) { + const errData = await uploadRes.json().catch(() => ({})); + throw new Error(errData.error || 'Ошибка загрузки файла'); + } + const uploaded = await uploadRes.json(); + + const newFiles = [...files, { url: uploaded.url, name: uploaded.name, size: uploaded.size }]; + await apiRequest('PATCH', `/api/tasks/${taskId}/field-values/${field.id}`, { + value: newFiles, + }); + queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] }); + queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'audit-log'] }); + if (formId) queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'tasks', 'with-fields'] }); + toast({ title: 'Файл загружен', description: uploaded.name }); + } catch (err: unknown) { + if (err instanceof OfflineQueuedError) return; + const msg = err instanceof Error ? err.message : 'Не удалось загрузить файл. Проверьте соединение и попробуйте ещё раз.'; + toast({ title: 'Ошибка загрузки', description: msg, variant: 'destructive' }); + } finally { + setIsUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + + const handleRemoveFile = async (index: number) => { + try { + const newFiles = files.filter((_, i) => i !== index); + await apiRequest('PATCH', `/api/tasks/${taskId}/field-values/${field.id}`, { + value: newFiles.length > 0 ? newFiles : null, + }); + queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] }); + queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'audit-log'] }); + if (formId) queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'tasks', 'with-fields'] }); + toast({ title: 'Файл удалён' }); + } catch (err: unknown) { + if (err instanceof OfflineQueuedError) return; + const msg = err instanceof Error ? err.message : 'Не удалось удалить файл. Проверьте соединение и попробуйте ещё раз.'; + toast({ title: 'Ошибка удаления', description: msg, variant: 'destructive' }); + } + }; + + return ( +
+
+ +
+
+ {field.name}:{' '} + {field.maxFileCount != null && ( + ({files.length}/{field.maxFileCount}) + )} + {isUploading ? ( + Загрузка... + ) : files.length > 0 ? ( + <> +
+ {files.map((fileInfo, idx) => { + const vType = detectViewerType(fileInfo.url, fileInfo.name); + const canPreview = vType === 'pdf' || vType === 'word' || vType === 'excel' || vType === 'powerpoint' || vType === 'video' || vType === 'audio' || vType === 'image'; + return ( + + {canPreview ? ( + + ) : ( + { e.preventDefault(); openFilePresigned(fileInfo.url); }} + target="_blank" + rel="noopener noreferrer" + className="inline-flex items-center gap-1 text-sm text-primary hover:underline min-w-0" + data-testid={`file-link-${field.code}-${idx}`} + > + + {fileInfo.name} + {fileInfo.size !== undefined && ( + ({formatFileSize(fileInfo.size)}) + )} + + + )} + + + ); + })} +
+ + ) : ( + Нет файла + )} +
+ + {viewerFile && ( + setViewerFile(null)} + /> + )} +
+ ); +} + +// Вспомогательная функция: получить значение из данных DaData по ключу +function getDadataFieldValue(key: string, company: DadataCompany): string { + switch (key) { + case 'name.full_with_opf': return company.data.name.full_with_opf || ''; + case 'name.short_with_opf': return company.data.name.short_with_opf || ''; + case 'inn': return company.data.inn || ''; + case 'kpp': return company.data.kpp || ''; + case 'ogrn': return company.data.ogrn || ''; + case 'management.name': return company.data.management?.name || ''; + case 'management.post': return company.data.management?.post || ''; + default: return ''; + } +} + +// Сравнение значений полей: null/undefined/''/false считаем пустыми и равными друг другу +function fieldValuesEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + const isEmpty = (v: unknown) => v === null || v === undefined || v === '' || v === false; + if (isEmpty(a) && isEmpty(b)) return true; + return JSON.stringify(a) === JSON.stringify(b); +} + +// Клик по интерактивному элементу внутри read-view (ссылка, кнопка, контрол) — не открываем редактор +export function isInteractiveClickTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + return !!target.closest('a, button, input, select, textarea, [role="button"], [role="checkbox"], [role="listbox"]'); +} + +// Клик пришёл из портала Radix (выпадающие списки редакторов) — не считаем «кликом снаружи» +export function isRadixPortalTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + return !!target.closest('[data-radix-popper-content-wrapper], [role="listbox"], [data-radix-select-viewport], [data-radix-menu-content], [data-radix-popover-content]'); +} + +export interface FieldValueRowProps { + field: FormFieldData; + value: string | number | boolean | null | undefined; + task?: TaskData; + taskId: number; + formId?: number; + users: InlineUserOption[]; + roles?: Array<{ id: number; name: string }>; + allFieldValues?: Record; + allFields?: FormFieldData[]; + statuses?: Array<{ id: number; position: number; isFinal?: boolean | null }>; + isPending?: (url: string) => boolean; + readOnly?: boolean; + errorMessage?: string; + onErrorClear?: () => void; + // Позиция label: 'inline' — в строку (как сейчас), 'top' — над значением. + // Если не задана — берётся из скина (Тема 2.0, components.label.position; дефолт 'inline'). + labelPosition?: 'inline' | 'top'; + // Дополнительные классы для label и строки (используются пресетами представления) + labelClassName?: string; + rowClassName?: string; +} + +// Компонент для отображения одного поля с per-field inline-редактированием: +// клик по значению открывает редактор только этой строки, сохранение — PATCH одного поля. +export function FieldValueRow({ + field, + value: initialValue, + task, + taskId, + formId, + users, + roles, + allFieldValues, + allFields, + statuses, + isPending, + readOnly, + errorMessage, + onErrorClear, + labelPosition, + labelClassName, + rowClassName, +}: FieldValueRowProps) { + const { toast } = useToast(); + const skinComponents = useSkinComponents(); + // Позиция label: проп пресета важнее настройки скина; дефолт скина — 'inline' (текущий вид) + const isLabelTop = (labelPosition ?? skinComponents.label.position) === 'top'; + + // Вычисляем условное состояние поля (required/immutable/visible) для отображения меток и валидации + const fieldState = useMemo(() => { + if (!allFields) return { required: false, immutable: false, visible: true }; + const fieldValuesArray = allFields.map((f) => ({ fieldId: f.id, value: allFieldValues?.[f.code] })); + const allConditionFields = getAllConditionFields(allFields as ClientFormField[]); + const context = buildRequiredConditionContext(users as any, roles || [], false, statuses); + const taskForConditions = task ?? null; + return computeFieldState( + field as ClientFormField, + taskForConditions, + buildClientFieldValues(taskForConditions, fieldValuesArray, statuses), + allConditionFields, + context + ); + }, [field, task, allFieldValues, allFields, users, roles, statuses]); + + const isEditable = INLINE_EDITABLE_TYPES.has(field.type); + + const [navGeo, setNavGeo] = useState(null); + const wrapperRef = useRef(null); + + // Двойное касание на мобильных — вход в редактирование (как до per-field рефакторинга) + const lastTapRef = useRef(0); + const handleDoubleTap = (e: React.TouchEvent) => { + if (!canEdit) return; + if (isInteractiveClickTarget(e.target)) return; + const now = Date.now(); + if (now - lastTapRef.current < 300) { + e.stopPropagation(); + enterEdit(); + } + lastTapRef.current = now; + }; + + // Per-field режим редактирования: локальное состояние только этой строки + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(initialValue); + const editValueRef = useRef(initialValue); + const [localError, setLocalError] = useState(null); + + // Очередь обновлений autofill-полей, которые нужно сохранить вместе с основным полем + const pendingAutofillRef = useRef>([]); + + // Флаги составных редакторов (сохранение по закрытию — клик вне строки) + const isProlongationDate = (field.type === 'date' || field.type === 'datetime') && !!field.autoProlongation; + const tableOpts = field.type === 'table' && field.options && !Array.isArray(field.options) + ? (field.options as Record) + : {}; + const isTableSubdirectory = field.type === 'table' && !!tableOpts.showSubdirectory; + const isCommitOnClose = + field.type === 'geo' || + field.type === 'contact' || + field.type === 'contract-number' || + field.type === 'company' || + isProlongationDate || + isTableSubdirectory; + + const canEdit = isEditable && !readOnly && !fieldState.immutable; + + // Пока поле не редактируется — синхронизируем локальный черновик со значением с сервера + useEffect(() => { + if (!isEditing) { + editValueRef.current = initialValue; + setEditValue(initialValue); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialValue, isEditing]); + + const updateEditValue = (val: unknown) => { + editValueRef.current = val; + setEditValue(val); + if (localError) setLocalError(null); + }; + + const enterEdit = () => { + if (isEditing || !isEditable || readOnly) return; + if (fieldState.immutable) { + toast({ + title: 'Редактирование недоступно', + description: `Поле «${field.name}» не может быть изменено в текущем статусе`, + variant: 'destructive', + }); + return; + } + editValueRef.current = initialValue; + setEditValue(initialValue); + setLocalError(null); + setIsEditing(true); + }; + + const cancelEdit = () => { + setIsEditing(false); + setLocalError(null); + // Сбрасываем очередь autofill-затирания (company onClear) — иначе она «утечёт» в следующее сохранение + pendingAutofillRef.current = []; + editValueRef.current = initialValue; + setEditValue(initialValue); + }; + + // Сохранение значения: required-валидация прямо в строке, затем PATCH одного поля + const commitValue = (val: unknown, opts?: { close?: boolean }) => { + const close = opts?.close !== false; + if (fieldState.required && isFieldValueEmpty(val, field.type)) { + setLocalError(`Поле «${field.name}» обязательно к заполнению`); + return; + } + setLocalError(null); + if (!fieldValuesEqual(val, initialValue)) { + saveValue(val as string | number | boolean | Record | null | undefined); + } else { + // Значение не изменилось — сохранение пропускаем, autofill-очередь затираем + pendingAutofillRef.current = []; + } + if (close) setIsEditing(false); + }; + + // Клик вне строки закрывает редактор: составные редакторы сохраняются, остальные отменяются + useEffect(() => { + if (!isEditing) return; + const handleOutsideClick = (e: MouseEvent) => { + const target = e.target; + if (wrapperRef.current?.contains(target as Node)) return; + if (isRadixPortalTarget(target)) return; + if (isCommitOnClose) { + commitValue(editValueRef.current); + } else { + cancelEdit(); + } + }; + document.addEventListener('click', handleOutsideClick); + return () => document.removeEventListener('click', handleOutsideClick); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEditing, isCommitOnClose]); + + // Esc — отмена редактирования без сохранения (для редакторов без собственной обработки) + useEffect(() => { + if (!isEditing) return; + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') cancelEdit(); + }; + document.addEventListener('keydown', handleEsc); + return () => document.removeEventListener('keydown', handleEsc); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEditing]); + + const saveValue = async (val: string | number | boolean | Record | null | undefined) => { + // Optimistic-обновление кэша детали задачи: read-view меняется мгновенно, без мерцания + const prevDetail = queryClient.getQueryData(['/api/tasks', taskId, 'detail']); + if (prevDetail) { + const prevFieldValues = prevDetail.fieldValues || []; + const idx = prevFieldValues.findIndex(fv => fv.fieldId === field.id); + const nextEntry: TaskFieldValueData = idx >= 0 + ? { ...prevFieldValues[idx], value: val as string | number | boolean | null } + : { id: -Date.now(), taskId, fieldId: field.id, value: val as string | number | boolean | null }; + const nextFieldValues = idx >= 0 + ? prevFieldValues.map((fv, i) => (i === idx ? nextEntry : fv)) + : [...prevFieldValues, nextEntry]; + const merged = { ...prevDetail, fieldValues: nextFieldValues }; + queryClient.setQueryData(['/api/tasks', taskId, 'detail'], merged); + saveTask(taskId, merged).catch(() => {}); + } + try { + await apiRequest('PATCH', `/api/tasks/${taskId}/field-values/${field.id}`, { value: val }); + // Сохраняем pending autofill-поля (при выборе/очистке компании) + const pendingUpdates = pendingAutofillRef.current; + if (pendingUpdates.length > 0) { + await Promise.all( + pendingUpdates.map(({ fieldId, value }) => + apiRequest('PATCH', `/api/tasks/${taskId}/field-values/${fieldId}`, { value }) + ) + ); + pendingAutofillRef.current = []; + } + queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] }); + queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'audit-log'] }); + onErrorClear?.(); + } catch (err: unknown) { + if (err instanceof OfflineQueuedError) { + // Основное поле поставлено в офлайн-очередь — ставим туда же autofill-поля + const pendingUpdates = pendingAutofillRef.current; + pendingAutofillRef.current = []; + if (pendingUpdates.length > 0) { + await Promise.allSettled( + pendingUpdates.map(({ fieldId, value }) => + apiRequest('PATCH', `/api/tasks/${taskId}/field-values/${fieldId}`, { value }) + ) + ); + } + return; + } + // Ошибка сохранения — откатываем optimistic-значение перезапросом детали + queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] }); + const msg = err instanceof Error ? err.message : 'Не удалось сохранить. Проверьте соединение и попробуйте ещё раз.'; + toast({ title: 'Ошибка сохранения', description: msg, variant: 'destructive' }); + } + }; + + // Отображение читаемого значения (без редактора) + const renderReadValue = () => { + // Формула — вычисляемое read-only поле с жёлтым фоном + if (field.type === 'formula') { + // The resolved value is already pre-computed (with topological ordering) in allFieldValues + const precomputed = allFieldValues ? allFieldValues[field.code] : undefined; + const result: string | number = (precomputed !== null && precomputed !== undefined) + ? (precomputed as string | number) + : ''; + const isCircular = result === FORMULA_CIRCULAR_ERROR; + const isError = result === FORMULA_ERROR || isCircular; + return ( + + {isError ? ( + + {String(result)} + + ) : result === '' ? ( + — + ) : ( + {String(result)} + )} + + ); + } + + if (initialValue === null || initialValue === undefined || initialValue === '') { + return —; + } + if (field.type === 'task' && field.linkedFormId) { + const taskIdVal = typeof initialValue === 'boolean' ? String(initialValue) : initialValue; + return ( + + + + ); + } + if (field.type === 'table' && field.options) { + const opts = field.options as { tableId?: number; displayColumn?: string }; + if (opts.tableId) { + const rowIdVal = typeof initialValue === 'boolean' ? String(initialValue) : initialValue; + return ( + + + + ); + } + } + if (field.type === 'company') { + return ( + + + {initialValue.toString()} + + ); + } + if (field.type === 'geo' && initialValue) { + let geo: any; + try { + geo = typeof initialValue === 'string' ? JSON.parse(initialValue) : initialValue; + } catch { + geo = null; + } + const display = geo?.address || `${geo?.lat || ''}, ${geo?.lng || ''}` || null; + if (!display) return —; + return ( + { e.preventDefault(); setNavGeo(geo); }} + className="text-primary hover:underline inline-flex items-center gap-1 cursor-pointer" + > + + {display} + + ); + } + if (field.type === 'checkbox' || field.type === 'toggle') { + const checked = initialValue === true || initialValue === 'true'; + return ( + {checked ? 'Да' : 'Нет'} + ); + } + if (field.type === 'checklist' && Array.isArray(initialValue)) { + const items = Array.isArray(field.options) ? field.options : []; + const checkedCount = initialValue.filter((v: any) => v?.checked).length; + const totalCount = items.length; + return ( + + Выполнено {checkedCount} из {totalCount} + + ); + } + if (field.type === 'date' || field.type === 'datetime') { + if (initialValue === '__auto_prolongation__') { + return {field.autoProlongationLabel || 'Автопролонгация'}; + } + if (initialValue) { + try { + const date = new Date(initialValue.toString()); + if (!isNaN(date.getTime())) { + return {date.toLocaleDateString('ru-RU')}; + } + } catch (_e) { /* показываем как есть */ } + } + } + if (field.type === 'user') { + return ( + + + + ); + } + if (field.type === 'select' || field.type === 'radio-group') { + return {getSelectOptionLabel(field.options, initialValue) || initialValue.toString()}; + } + if (field.type === 'textarea' || field.type === 'rich-text') { + return ( +
+ +
+ ); + } + if (field.type === 'geo') { + if (!initialValue) return —; + const geo = initialValue as GeoValue; + const lat = typeof geo.lat === 'string' ? parseFloat(geo.lat) : geo.lat; + const lng = typeof geo.lng === 'string' ? parseFloat(geo.lng) : geo.lng; + const display = geo.address || `${lat}, ${lng}`; + return ( + { e.preventDefault(); setNavGeo(geo); }} + className="inline-flex items-center gap-1 text-primary hover:underline cursor-pointer" + data-testid={`text-custom-field-${field.code}`} + > + + {display} + + ); + } + if (field.type === 'contract-number' && initialValue) { + return ( + + + + ); + } + if (field.type === 'contact' && initialValue) { + let parsed: any; + try { + parsed = typeof initialValue === 'string' ? JSON.parse(initialValue) : initialValue; + } catch { + parsed = null; + } + if (!parsed?.name && !parsed?.position && !parsed?.phones?.length && !parsed?.emails?.length) { + return —; + } + const line1 = [parsed.name, parsed.position].filter(Boolean).join(' '); + const line2Parts: string[] = []; + if (parsed.phones?.length) line2Parts.push(parsed.phones.join(', ')); + if (parsed.emails?.length) line2Parts.push(parsed.emails.join(', ')); + const line2 = line2Parts.join(' | '); + return ( +
+ {line1 && {line1}} + {line2 && {line2}} +
+ ); + } + return {safeString(initialValue)}; + }; + + // Рендер редактора по типу поля (per-field: сохранение по blur/Enter/выбору/закрытию) + const renderEditor = () => { + // Единый стиль контрола карточки задачи (detail-вариант дизайн-системы полей) + const inputClass = cn( + fieldControlClass('detail'), + 'leading-6', + effectiveError && 'border-destructive ring-destructive' + ); + const textareaClass = cn( + 'border border-input rounded px-1.5 py-0.5 text-base md:text-[13px] bg-background focus:outline-none focus:ring-1 focus:ring-ring w-auto min-w-[200px] max-w-[420px]', + effectiveError && 'border-destructive ring-destructive' + ); + // Текст и число: сохранение по blur и Enter, Esc — отмена + if (field.type === 'text' || field.type === 'number') { + return ( + updateEditValue(field.type === 'number' ? e.target.value.replace(/\s/g, '') : e.target.value)} + onClick={e => e.stopPropagation()} + autoFocus + aria-label={field.name} + aria-invalid={!!effectiveError} + aria-describedby={effectiveError ? fieldErrorId : undefined} + onBlur={() => commitValue(editValueRef.current)} + onKeyDown={e => { + if (e.key === 'Enter') { e.preventDefault(); commitValue(editValueRef.current); } + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); } + }} + /> + ); + } + // Многострочный текст: сохранение по blur, Esc — отмена (Enter — новая строка) + if (field.type === 'textarea' || field.type === 'rich-text') { + return ( + updateEditValue(e.target.value)} + onClick={e => e.stopPropagation()} + autoFocus + aria-label={field.name} + aria-invalid={!!effectiveError} + aria-describedby={effectiveError ? fieldErrorId : undefined} + placeholder={field.type === 'rich-text' ? 'HTML: жирный, курсив,
  • список
' : undefined} + onBlur={() => commitValue(editValueRef.current)} + onKeyDown={e => { + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); } + }} + /> + ); + } + // Дата/время без автопролонгации: сохранение по выбору (change), ✕ — очистить, Esc — отмена + if ((field.type === 'date' || field.type === 'datetime') && !field.autoProlongation) { + const isDateTime = field.type === 'datetime'; + const completeRe = isDateTime ? /^.{16}$/ : /^\d{4}-\d{2}-\d{2}$/; + return ( +
+ { + const v = e.target.value; + updateEditValue(v); + if (v === '' || completeRe.test(v)) commitValue(v); + }} + onClick={e => e.stopPropagation()} + autoFocus + aria-label={field.name} + aria-invalid={!!effectiveError} + aria-describedby={effectiveError ? fieldErrorId : undefined} + onKeyDown={e => { + if (e.key === 'Enter') { e.preventDefault(); commitValue(editValueRef.current); } + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); } + }} + /> + {editValue ? ( + + ) : null} +
+ ); + } + // Дата/время с автопролонгацией: составной редактор, сохранение по закрытию (клик вне / Enter) + if (field.type === 'date' || field.type === 'datetime') { + const isProlongation = editValue === '__auto_prolongation__'; + return ( +
e.stopPropagation()}> + updateEditValue(e.target.value)} + autoFocus + disabled={isProlongation} + aria-label={field.name} + aria-invalid={!!effectiveError} + aria-describedby={effectiveError ? fieldErrorId : undefined} + onKeyDown={e => { + if (e.key === 'Enter') { e.preventDefault(); commitValue(editValueRef.current); } + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); } + }} + /> + +
+ ); + } + if (field.type === 'select' || field.type === 'radio-group') { + const opts = normalizeSelectOptions(field.options); + // Мультивыбор: сохранение по каждому переключению, выход — клик вне / Esc + if (field.allowMultiple) { + const selected = Array.isArray(editValue) + ? new Set(editValue.map((v: unknown) => String(v))) + : editValue != null && editValue !== '' + ? new Set([String(editValue)]) + : new Set(); + return ( +
e.stopPropagation()}> + ({ value: o.value, label: o.label }))} + selected={selected} + onChange={(next) => { + const arr = Array.from(next); + updateEditValue(arr); + commitValue(arr, { close: false }); + }} + /> +
+ ); + } + // Одиночный выбор: сохранение сразу по выбору + return ( + + ); + } + // Чек-лист: сохранение по каждому переключению, выход — клик вне / Esc + if (field.type === 'checklist') { + const items = Array.isArray(field.options) ? field.options : []; + const valueArr = Array.isArray(editValue) ? (editValue as Array<{ itemId?: string; id?: string; checked?: boolean }>) : []; + return ( +
e.stopPropagation()}> + {items.map((item: unknown, idx: number) => { + const it = item as { id?: string; value?: string; label?: string; name?: string; period?: string }; + const itemId = it.id || it.value || String(idx); + const itemLabel = it.label || it.name || String(item); + const checkedItem = valueArr.find((v) => (v.itemId || v.id) === itemId); + const isChecked = !!checkedItem?.checked; + return ( + + ); + })} +
+ ); + } + // Пользователь/роль: сохранение сразу по выбору (radix Select) + if (field.type === 'user') { + return ( +
e.stopPropagation()}> + commitValue(val)} + users={users} + roles={roles} + autoOpen + /> +
+ ); + } + // Гео: составной редактор, сохранение по закрытию + if (field.type === 'geo') { + return ( +
e.stopPropagation()}> + updateEditValue(val)} + /> +
+ ); + } + // Компания: выбор подсказки сохраняет сразу (с autofill), закрытие — сохраняет введённый текст + if (field.type === 'company') { + return ( +
e.stopPropagation()}> + updateEditValue(val)} + onSelect={company => { + updateEditValue(company.value); + if (field.companyAutofill && allFields) { + pendingAutofillRef.current = Object.entries(field.companyAutofill) + .filter(([, code]) => code && code !== 'none') + .flatMap(([dadataKey, code]) => { + const targetField = allFields.find(f => f.code === code); + return targetField ? [{ fieldId: targetField.id, value: getDadataFieldValue(dadataKey, company) }] : []; + }); + } + commitValue(company.value); + }} + onClear={() => { + if (field.companyAutofill && allFields) { + pendingAutofillRef.current = Object.values(field.companyAutofill) + .filter(code => code && code !== 'none') + .flatMap(code => { + const targetField = allFields.find(f => f.code === code); + return targetField ? [{ fieldId: targetField.id, value: '' }] : []; + }); + } + }} + /> +
+ ); + } + // Номер договора: составной редактор, сохранение по закрытию + if (field.type === 'contract-number') { + const cnConfig = (field.options as any)?.contractNumber; + return ( +
e.stopPropagation()}> + updateEditValue(JSON.stringify(val))} + config={cnConfig} + formId={formId} + fieldId={field.id} + compact + /> +
+ ); + } + if (field.type === 'table') { + const showSubdirectory = isTableSubdirectory; + // Подсправочник: составной редактор, сохранение по закрытию + if (showSubdirectory) { + return ( +
e.stopPropagation()}> + updateEditValue(val)} + allowMultiple={field.allowMultiple || false} + size="detail" + /> +
+ ); + } + // Мультивыбор строк: сохранение по каждому переключению + if (field.allowMultiple) { + return ( +
e.stopPropagation()}> + { + updateEditValue(val); + commitValue(val, { close: false }); + }} + /> +
+ ); + } + // Одиночный выбор строки: сохранение сразу по выбору + return ( +
e.stopPropagation()}> + commitValue(val)} + /> +
+ ); + } + // Контакт: составной редактор, сохранение по закрытию + if (field.type === 'contact') { + return ( +
e.stopPropagation()}> + updateEditValue(JSON.stringify(val))} + /> +
+ ); + } + // Связанная задача: сохранение сразу по выбору + if (field.type === 'task' && field.linkedFormId) { + return ( +
e.stopPropagation()}> + commitValue(val)} + showCreateButton={false} + size="detail" + /> +
+ ); + } + return null; + }; + + // Кнопка — всегда read-only (своя логика действий) + if (field.type === 'button') { + return ; + } + + // Файл — своя логика загрузки + if (field.type === 'file') { + return ; + } + + // Шаблон документа — выбор шаблона и генерация + if (field.type === 'document_template') { + return ( + saveValue(val)} + readOnly={readOnly} + /> + ); + } + + const fieldUrl = `/api/tasks/${taskId}/field-values/${field.id}`; + const isFieldPending = isPending ? isPending(fieldUrl) : false; + const effectiveError = errorMessage || localError; + const fieldErrorId = `fv-error-${field.id}`; + // checkbox/toggle — мгновенное переключение по клику, без входа в режим редактирования + const isInstantToggle = field.type === 'checkbox' || field.type === 'toggle'; + const editorNode = isEditing && !isInstantToggle ? renderEditor() : null; + + // Значение поля: мгновенный переключатель / редактор / read-view (общий для обеих раскладок label) + const valueNode = isInstantToggle ? ( + + ) : isEditing && editorNode != null ? ( +
+ {editorNode} +
+ ) : ( +
{ + if (!canEdit) return; + if (isInteractiveClickTarget(e.target)) return; + e.stopPropagation(); + enterEdit(); + }} + onTouchEnd={handleDoubleTap} + > + {renderReadValue()} +
+ ); + + return ( + <> +
+ {/* Левая колонка: индикаторы обязательности / неизменяемости / карандаш */} +
+ {isFieldPending ? ( + + + + ) : fieldState.immutable ? ( + + ) : fieldState.required ? ( + + * + + ) : ( + /* Карандаш: на мобильном всегда виден, на десктопе — по hover строки или фокусу */ + isEditable && !readOnly && !isEditing && ( + + ) + )} +
+ + {/* Контент поля: read-view и редактор занимают один и тот же слот (без оверлеев) */} +
+ {isLabelTop ? ( + <> + {/* Вертикальная раскладка (label.position === 'top'): label-ряд сверху h-5, значение снизу */} +
+ + {field.name} + +
+
+ {valueNode} +
+ + ) : ( +
+ + {field.name}: + + {valueNode} +
+ )} + {isFieldPending && ( + + + ожидает + + )} + {effectiveError && ( + {effectiveError} + )} +
+
+ { if (!open) setNavGeo(null); }} + /> + + ); +} diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx index f50ec39..b473b5f 100644 --- a/client/src/components/ui/button.tsx +++ b/client/src/components/ui/button.tsx @@ -5,7 +5,8 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-[color,background-color,border-color,box-shadow,transform] duration-150 active:not-disabled:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + // Тема 2.0: радиус кнопок — var(--button-radius), fallback = прежний rounded-md + "inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-[var(--button-radius,calc(var(--radius)_-_2px))] text-sm font-medium ring-offset-background transition-[color,background-color,border-color,box-shadow,transform] duration-150 active:not-disabled:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { diff --git a/client/src/components/ui/dialog.tsx b/client/src/components/ui/dialog.tsx index 975cf5b..8669ea5 100644 --- a/client/src/components/ui/dialog.tsx +++ b/client/src/components/ui/dialog.tsx @@ -5,6 +5,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog" import { X } from "lucide-react" import { cn } from "@/lib/utils" +import { useSkinComponents } from "@/themes/ThemeProvider" const Dialog = DialogPrimitive.Root @@ -17,40 +18,54 @@ const DialogClose = DialogPrimitive.Close const DialogOverlay = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) +>(({ className, ...props }, ref) => { + // Тема 2.0: backdrop 'blur' добавляет размытие фона под диалогом + const { dialog } = useSkinComponents() + return ( + + ) +}) DialogOverlay.displayName = DialogPrimitive.Overlay.displayName +// Тема 2.0: dialog.mobile === 'sheet' — на мобильных диалог становится full-screen +// (паттерн из CreateTaskDialog), на десктопе вид не меняется +const MOBILE_SHEET_CLASSES = + "max-md:left-0 max-md:right-0 max-md:top-0 max-md:bottom-0 max-md:translate-x-0 max-md:translate-y-0 max-md:w-full max-md:h-dvh max-md:max-w-none max-md:max-h-none max-md:rounded-none" + const DialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - {children} - - - Close - - - -)) +>(({ className, children, ...props }, ref) => { + const { dialog } = useSkinComponents() + return ( + + + + {children} + + + Close + + + + ) +}) DialogContent.displayName = DialogPrimitive.Content.displayName const DialogHeader = ({ diff --git a/client/src/components/ui/table.tsx b/client/src/components/ui/table.tsx index c25d708..0de9d85 100644 --- a/client/src/components/ui/table.tsx +++ b/client/src/components/ui/table.tsx @@ -1,6 +1,7 @@ import * as React from "react" import { cn } from "@/lib/utils" +import { useSkinComponents } from "@/themes/ThemeProvider" const Table = React.forwardRef< HTMLTableElement, @@ -19,9 +20,17 @@ Table.displayName = "Table" const TableHeader = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)) +>(({ className, ...props }, ref) => { + // Тема 2.0: шапка таблицы — muted (с фоном, дефолт) или plain (без фона) + const { table } = useSkinComponents() + return ( + + ) +}) TableHeader.displayName = "TableHeader" const TableBody = React.forwardRef< @@ -54,16 +63,22 @@ TableFooter.displayName = "TableFooter" const TableRow = React.forwardRef< HTMLTableRowElement, React.HTMLAttributes ->(({ className, ...props }, ref) => ( - -)) +>(({ className, ...props }, ref) => { + // Тема 2.0: высота строки — var(--table-row-height); разделители — rows (дефолт) / grid / none + const { table } = useSkinComponents() + return ( + td]:border-x", + className + )} + {...props} + /> + ) +}) TableRow.displayName = "TableRow" const TableHead = React.forwardRef< @@ -73,7 +88,7 @@ const TableHead = React.forwardRef<
f.type !== 'company' && (tabId === undefined || f.tabId === tabId) && props.isFieldVisibleForTask(f) + ); +} + +interface PresetFieldProps { + preset: LayoutPresetProps; + field: FormFieldData; + labelPosition?: 'inline' | 'top'; + labelClassName?: string; + rowClassName?: string; +} + +// Строка поля пресета: та же механика, что у TabFieldsContent в TaskDetail — +// значение с учётом офлайн-очереди, per-field редактирование через FieldValueRow. +export function PresetField({ preset, field, labelPosition, labelClassName, rowClassName }: PresetFieldProps) { + const { isPending: isUrlPending, getPendingBody } = useOfflineSyncContext(); + const fieldValue = preset.fieldValues.find((fv: TaskFieldValueData) => fv.fieldId === field.id); + const fieldUrl = `/api/tasks/${preset.task.id}/field-values/${field.id}`; + const pendingBody = isUrlPending(fieldUrl) ? getPendingBody(fieldUrl) : null; + const effectiveValue = (pendingBody && 'value' in pendingBody) + ? (pendingBody as { value: string | number | boolean | null | undefined }).value + : fieldValue?.value; + + return ( + + preset.setFieldErrors?.((prev) => { const next = { ...prev }; delete next[field.id]; return next; })} + labelPosition={labelPosition} + labelClassName={labelClassName} + rowClassName={rowClassName} + /> + + ); +} diff --git a/client/src/layouts/PyrusPreset.tsx b/client/src/layouts/PyrusPreset.tsx new file mode 100644 index 0000000..0e48af0 --- /dev/null +++ b/client/src/layouts/PyrusPreset.tsx @@ -0,0 +1,58 @@ +import type { LayoutPresetProps } from './types'; +import { PresetField, getPresetVisibleFields } from './PresetField'; + +// Пресет «Pyrus»: плотный вид, секции по вкладкам формы с разделителями, +// label uppercase 11px muted, значения 13px, строки с border-b. +export function PyrusPreset(props: LayoutPresetProps) { + // Секции: при выбранной вкладке (desktop) — только она; иначе — все вкладки с полями + блок без вкладки + const sections: Array<{ key: string; title: string | null; tabId?: number }> = []; + + if (props.tabId !== undefined) { + const tab = props.tabs.find(t => t.id === props.tabId); + sections.push({ key: `tab-${props.tabId}`, title: tab?.name ?? null, tabId: props.tabId }); + } else if (props.tabs.length > 0) { + for (const tab of props.tabs) { + if (getPresetVisibleFields(props, tab.id).length > 0) { + sections.push({ key: `tab-${tab.id}`, title: tab.name, tabId: tab.id }); + } + } + const untabbed = getPresetVisibleFields(props, undefined).filter(f => f.tabId == null || !props.tabs.some(t => t.id === f.tabId)); + if (untabbed.length > 0) { + sections.push({ key: 'untabbed', title: 'Прочее', tabId: undefined }); + } + } else { + sections.push({ key: 'all', title: null, tabId: undefined }); + } + + return ( +
+ {sections.map((section) => { + const fields = section.key === 'untabbed' + ? getPresetVisibleFields(props, undefined).filter(f => f.tabId == null || !props.tabs.some(t => t.id === f.tabId)) + : getPresetVisibleFields(props, section.tabId); + if (fields.length === 0) return null; + return ( +
+ {section.title && ( +
+ {section.title} +
+ )} +
+ {fields.map((field) => ( + + ))} +
+
+ ); + })} +
+ ); +} diff --git a/client/src/layouts/VerticalPreset.tsx b/client/src/layouts/VerticalPreset.tsx new file mode 100644 index 0000000..b37a28b --- /dev/null +++ b/client/src/layouts/VerticalPreset.tsx @@ -0,0 +1,29 @@ +import { cn } from '@/lib/utils'; +import type { LayoutPresetProps } from './types'; +import { PresetField, getPresetVisibleFields } from './PresetField'; + +// Широкие типы полей занимают обе колонки сетки +const WIDE_FIELD_TYPES = new Set(['textarea', 'rich-text', 'geo', 'contact', 'company', 'file', 'checklist']); + +// Пресет «Вертикальное» (NocoBase-стиль): label сверху, сетка в 2 колонки на desktop +export function VerticalPreset(props: LayoutPresetProps) { + const visibleFields = getPresetVisibleFields(props, props.tabId); + + if (visibleFields.length === 0) { + return ( +
+

Нет полей для отображения

+
+ ); + } + + return ( +
+ {visibleFields.map((field) => ( +
+ +
+ ))} +
+ ); +} diff --git a/client/src/layouts/index.ts b/client/src/layouts/index.ts new file mode 100644 index 0000000..1e3b94a --- /dev/null +++ b/client/src/layouts/index.ts @@ -0,0 +1,48 @@ +import type { LayoutPresetDef } from './types'; +import { VerticalPreset } from './VerticalPreset'; +import { PyrusPreset } from './PyrusPreset'; + +// Реестр встроенных пресетов представления области полей карточки задачи. +// Выбор хранится в forms.detail_layout_code маркером `@preset:` (первая строка) — без миграций. +// compact — passthrough: пресет = отсутствие кастомного layout (стандартный вид карточки). +export const LAYOUT_PRESETS: Record = { + compact: { + id: 'compact', + name: 'Стандартное (компактное)', + description: 'Текущий компактный вид карточки: label и значение в одной строке.', + }, + vertical: { + id: 'vertical', + name: 'Вертикальное', + description: 'Label над значением, сетка в две колонки на десктопе (стиль NocoBase).', + component: VerticalPreset, + }, + pyrus: { + id: 'pyrus', + name: 'Pyrus', + description: 'Плотный вид: секции по вкладкам с разделителями, label uppercase, строки с border-b.', + component: PyrusPreset, + }, +}; + +export type ParsedDetailLayout = + | { kind: 'preset'; preset: LayoutPresetDef } + | { kind: 'js'; code: string } + | { kind: 'standard' }; + +// Разбор detailLayoutCode: маркер `@preset:` (первая строка), JS-код или стандартный вид. +// Неизвестный preset-id и compact трактуются как стандартный вид (безопасный fallback). +export function parseDetailLayout(code?: string | null): ParsedDetailLayout { + const trimmed = (code ?? '').trim(); + if (!trimmed) return { kind: 'standard' }; + const firstLine = trimmed.split('\n', 1)[0].trim(); + const match = firstLine.match(/^@preset:([a-z0-9-]+)$/); + if (match) { + const preset = LAYOUT_PRESETS[match[1]]; + if (preset?.component) return { kind: 'preset', preset }; + return { kind: 'standard' }; + } + return { kind: 'js', code: trimmed }; +} + +export type { LayoutPresetProps, LayoutPresetDef } from './types'; diff --git a/client/src/layouts/types.ts b/client/src/layouts/types.ts new file mode 100644 index 0000000..f650907 --- /dev/null +++ b/client/src/layouts/types.ts @@ -0,0 +1,38 @@ +import type { ComponentType, Dispatch, SetStateAction } from 'react'; +import type { FormFieldData, TaskData, TaskFieldValueData } from '@/components/task-fields/FieldValueRow'; + +// Пропсы пресета представления области полей карточки задачи. +// Набор полей повторяет то, что TaskDetail передаёт в JsComponentRenderer (extraCtx), +// плюс данные для per-field редактирования через FieldValueRow. +export interface LayoutPresetProps { + task: TaskData; + formId: number; + form: { id: number; name: string }; + fields: FormFieldData[]; + fieldValues: TaskFieldValueData[]; + fieldValuesMap: Record; + tabs: Array<{ id: number; name: string; code?: string; type?: string; position?: number }>; + statuses: Array<{ id: number; name?: string; color?: string; position: number; isInitial?: boolean; isFinal?: boolean | null }>; + users: Array<{ id: number; email: string; firstName?: string | null; lastName?: string | null; middleName?: string | null; fullName?: string }>; + roles: Array<{ id: number; name: string }>; + // Доступные переходы статусов и исполнители задачи (слоты расширения ctx) + transitions?: unknown[]; + assignees?: unknown[]; + // Desktop: id текущей вкладки полей; undefined — все поля (нет вкладок / mobile / preview) + tabId?: number; + mode: 'desktop' | 'mobile' | 'preview'; + // readOnly: финальный статус задачи или предпросмотр в редакторе формы + readOnly?: boolean; + conditionContextStatuses: Array<{ id: number; name?: string; position: number; isFinal?: boolean | null }>; + isFieldVisibleForTask: (field: FormFieldData) => boolean; + fieldErrors?: Record; + setFieldErrors?: Dispatch>>; +} + +export interface LayoutPresetDef { + id: string; + name: string; + description: string; + // compact — намеренно без компонента: passthrough на стандартный вид карточки + component?: ComponentType; +} diff --git a/client/src/pages/AppearanceSettings.tsx b/client/src/pages/AppearanceSettings.tsx index 06e88e5..406668a 100644 --- a/client/src/pages/AppearanceSettings.tsx +++ b/client/src/pages/AppearanceSettings.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect } from 'react'; +import { useState, useMemo, useEffect, useRef } from 'react'; import { useQuery, useMutation } from '@tanstack/react-query'; import { queryClient, apiRequest } from '@/lib/queryClient'; import { useToast } from '@/hooks/use-toast'; @@ -8,6 +8,7 @@ import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Badge } from '@/components/ui/badge'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { useSkin, type Theme } from '@/themes/ThemeProvider'; import { EDITABLE_COLOR_TOKENS, @@ -17,7 +18,7 @@ import { type SkinThemeTokens, generateSkinCss, } from '@/themes/tokens'; -import { Loader2, Palette, Copy, Trash2, Check, Sparkles } from 'lucide-react'; +import { Loader2, Palette, Copy, Trash2, Check, Sparkles, Download, Upload } from 'lucide-react'; interface SkinListItem { id: string; @@ -70,6 +71,143 @@ function setTokenValue( return next; } +/** Тема 2.0: чтение общего токена (components/density/spacing) — из светлой темы. */ +function getSharedTokenValue( + tokens: { light: SkinThemeTokens; dark: SkinThemeTokens }, + path: string, +): string | number | boolean | undefined { + const parts = path.split('.'); + let current: any = tokens.light; + for (const part of parts) { + current = current?.[part]; + } + return current; +} + +/** Тема 2.0: запись общего токена сразу в обе темы; пустое значение удаляет ключ. */ +function setSharedTokenValue( + tokens: { light: SkinThemeTokens; dark: SkinThemeTokens }, + path: string, + value: string | number | boolean, +): { light: SkinThemeTokens; dark: SkinThemeTokens } { + const next: { light: SkinThemeTokens; dark: SkinThemeTokens } = { + light: cloneTokens(tokens.light), + dark: cloneTokens(tokens.dark), + }; + const parts = path.split('.'); + for (const theme of ['light', 'dark'] as const) { + let current: any = next[theme]; + for (let i = 0; i < parts.length - 1; i++) { + if (!current[parts[i]] || typeof current[parts[i]] !== 'object') { + current[parts[i]] = {}; + } + current = current[parts[i]]; + } + if (value === '') { + delete current[parts[parts.length - 1]]; + } else { + current[parts[parts.length - 1]] = value; + } + } + return next; +} + +/** Тема 2.0: селекты структурных вариантов компонентов для редактора скина. */ +const COMPONENT_SELECTS: Array<{ + path: string; + label: string; + defaultValue: string; + options: Array<{ value: string; label: string }>; + numeric?: boolean; +}> = [ + { + path: 'density.preset', + label: 'Плотность', + defaultValue: 'comfortable', + options: [ + { value: 'compact', label: 'Компактная' }, + { value: 'comfortable', label: 'Комфортная' }, + { value: 'spacious', label: 'Просторная' }, + ], + }, + { + path: 'components.control.variant', + label: 'Вид полей', + defaultValue: 'outline', + options: [ + { value: 'outline', label: 'Контурные' }, + { value: 'filled', label: 'Залитые' }, + { value: 'underline', label: 'Подчёркнутые' }, + ], + }, + { + path: 'components.label.position', + label: 'Подпись поля', + defaultValue: 'inline', + options: [ + { value: 'top', label: 'Сверху' }, + { value: 'inline', label: 'В строку' }, + ], + }, + { + path: 'components.form.columns', + label: 'Колонки формы', + defaultValue: '2', + numeric: true, + options: [ + { value: '1', label: '1 колонка' }, + { value: '2', label: '2 колонки' }, + ], + }, + { + path: 'components.dialog.mobile', + label: 'Диалоги на мобильном', + defaultValue: 'modal', + options: [ + { value: 'modal', label: 'Окно' }, + { value: 'sheet', label: 'На весь экран' }, + ], + }, + { + path: 'components.table.dividers', + label: 'Разделители таблиц', + defaultValue: 'rows', + options: [ + { value: 'rows', label: 'Строки' }, + { value: 'grid', label: 'Сетка' }, + { value: 'none', label: 'Без линий' }, + ], + }, + { + path: 'components.button.radius', + label: 'Радиус кнопок', + defaultValue: 'md', + options: [ + { value: 'sm', label: 'Малый' }, + { value: 'md', label: 'Средний' }, + { value: 'pill', label: 'Капсула' }, + ], + }, +]; + +/** Тема 2.0: текстовые переопределения отступов (пусто = дефолт). */ +const SPACING_OVERRIDE_INPUTS: Array<{ path: string; label: string; placeholder: string }> = [ + { path: 'spacing.fieldGapY', label: 'Отступ полей Y', placeholder: '8px' }, + { path: 'spacing.fieldGapX', label: 'Отступ полей X', placeholder: '16px' }, + { path: 'spacing.controlPaddingX', label: 'Паддинг контрола', placeholder: '10px' }, + { path: 'spacing.sectionGap', label: 'Отступ секций', placeholder: '16px' }, + { path: 'spacing.pageGutter', label: 'Поля страницы', placeholder: '16px' }, +]; + +/** Тема 2.0: текстовые переопределения высот (пусто = пресет плотности). */ +const DENSITY_OVERRIDE_INPUTS: Array<{ path: string; label: string; placeholder: string }> = [ + { path: 'density.controlForm', label: 'Высота контрола (форма)', placeholder: '32px' }, + { path: 'density.controlDetail', label: 'Высота контрола (карточка)', placeholder: '28px' }, + { path: 'density.controlCell', label: 'Высота контрола (ячейка)', placeholder: '28px' }, + { path: 'density.tableRow', label: 'Высота строки таблицы', placeholder: '40px' }, + { path: 'density.fieldRow', label: 'Высота строки поля', placeholder: '28px' }, +]; + export function AppearanceSettings() { const { toast } = useToast(); const { skinId: currentSkinId, setSkinId, availableSkins } = useSkin(); @@ -164,6 +302,87 @@ export function AppearanceSettings() { onError: () => toast({ title: 'Ошибка удаления скина', variant: 'destructive' }), }); + // Импорт темы из JSON-файла: создаёт кастомный скин через существующий POST + const importFileRef = useRef(null); + const importMutation = useMutation({ + mutationFn: async (payload: { name: string; description?: string; tokens: Skin['tokens'] }) => { + const res = await apiRequest('POST', '/api/organization/skins', { + skinId: `custom-${Date.now()}`, + name: payload.name, + description: payload.description, + tokens: payload.tokens, + }); + return res.json() as Promise<{ success: boolean; skin: Skin; dbId: number }>; + }, + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ['/api/organization/skins'] }); + setSelectedSkinId(result.skin.id); + toast({ title: 'Тема импортирована' }); + }, + onError: () => toast({ title: 'Ошибка импорта темы', variant: 'destructive' }), + }); + + /** Экспорт скина в JSON-файл ({ id, name, description, tokens }). */ + const handleExport = (skin: Skin) => { + if (!skin.tokens?.light || !skin.tokens?.dark) { + toast({ title: 'Нет токенов для экспорта', variant: 'destructive' }); + return; + } + const payload = { + id: skin.id, + name: skin.name, + description: skin.description, + tokens: skin.tokens, + }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${skin.id}.json`; + link.click(); + URL.revokeObjectURL(url); + }; + + /** Экспорт по элементу списка: токены берём из доступных скинов или подгружаем детали. */ + const handleExportById = async (item: SkinListItem) => { + const withTokens = availableSkins.find((s) => s.id === item.id && s.tokens?.light); + if (withTokens) { + handleExport(withTokens); + return; + } + try { + const res = await apiRequest('GET', `/api/organization/skins/${encodeURIComponent(item.id)}`); + const data = await res.json(); + if (data?.skin) { + handleExport(data.skin as Skin); + } else { + toast({ title: 'Не удалось загрузить скин для экспорта', variant: 'destructive' }); + } + } catch { + toast({ title: 'Ошибка экспорта темы', variant: 'destructive' }); + } + }; + + /** Импорт темы: валидация структуры (id + tokens.light/dark обязательны). */ + const handleImportFile = async (file: File) => { + let parsed: any; + try { + parsed = JSON.parse(await file.text()); + } catch { + toast({ title: 'Не удалось прочитать файл: это не JSON', variant: 'destructive' }); + return; + } + if (!parsed || typeof parsed.id !== 'string' || !parsed.tokens?.light || !parsed.tokens?.dark) { + toast({ title: 'Неверная структура файла: нужны id и tokens.light/tokens.dark', variant: 'destructive' }); + return; + } + importMutation.mutate({ + name: typeof parsed.name === 'string' && parsed.name ? parsed.name : parsed.id, + description: typeof parsed.description === 'string' ? parsed.description : undefined, + tokens: parsed.tokens, + }); + }; + // Когда выбран встроенный скин, берём токены из availableSkins. // Когда выбран кастомный — из формы или загружаем детали. const effectiveSkin = selectedSkin ?? selectedSkinData?.skin; @@ -190,6 +409,18 @@ export function AppearanceSettings() { return generateSkinCss({ ...effectiveSkin, tokens } as Skin, previewTheme); }, [effectiveSkin, form, previewTheme]); + // Тема 2.0: обновление общего токена (components/density/spacing) в обеих темах + const updateSharedToken = (path: string, value: string | number | boolean) => { + if (!effectiveSkin) return; + const base = form?.tokens ?? effectiveSkin.tokens; + const next = setSharedTokenValue(base, path, value); + setForm((prev) => ({ + name: prev?.name ?? effectiveSkin.name, + description: prev?.description ?? effectiveSkin.description ?? '', + tokens: next, + })); + }; + if (isLoading) { return (
@@ -270,6 +501,7 @@ export function AppearanceSettings() { size="sm" variant="outline" className="h-7 px-2" + title="Дублировать скин" onClick={(e) => { e.stopPropagation(); createMutation.mutate({ baseSkinId: skin.id, name: `${skin.name} (копия)` }); @@ -277,6 +509,19 @@ export function AppearanceSettings() { > + {!skin.isBuiltIn && skin.isEditable && ( + + { + const file = e.target.files?.[0]; + if (file) handleImportFile(file); + e.target.value = ''; + }} + />
@@ -436,6 +700,70 @@ export function AppearanceSettings() { +
+

Компоненты и отступы

+

+ Тема 2.0: плотность, вид полей и варианты компонентов. Применяется к обеим темам сразу. +

+
+ {COMPONENT_SELECTS.map((item) => { + const raw = getSharedTokenValue(form?.tokens ?? effectiveSkin.tokens, item.path); + const value = String(raw ?? item.defaultValue); + return ( +
+ + +
+ ); + })} +
+ + +
+
+
+ {[...SPACING_OVERRIDE_INPUTS, ...DENSITY_OVERRIDE_INPUTS].map((item) => { + const value = (getSharedTokenValue(form?.tokens ?? effectiveSkin.tokens, item.path) as string) ?? ''; + return ( +
+ + updateSharedToken(item.path, e.target.value)} + className="h-8 text-xs" + /> +
+ ); + })} +
+
+
)} @@ -4705,22 +4797,28 @@ return function Layout({ ctx }) { - {/* Preview Dialog — рендерит JS-оформление с образцовыми данными */} + {/* Preview Dialog — рендерит пресет или JS-оформление с образцовыми данными */} - Предпросмотр JS-оформления + Предпросмотр оформления Образцовые данные — поля и статусы из текущей формы
- + {PreviewPresetComponent ? ( + + + + ) : ( + + )}
diff --git a/client/src/pages/TaskDetail.tsx b/client/src/pages/TaskDetail.tsx index 69b5246..27e7ce4 100644 --- a/client/src/pages/TaskDetail.tsx +++ b/client/src/pages/TaskDetail.tsx @@ -4,35 +4,27 @@ import { useQuery, useMutation } from '@tanstack/react-query'; import Layout from '@/components/Layout'; import { Sidebar } from '@/components/Sidebar'; import { Button } from '@/components/ui/button'; -import { CreateTaskDialog } from '@/components/CreateTaskDialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'; -import { Checkbox } from '@/components/ui/checkbox'; import { Textarea } from '@/components/ui/textarea'; -import { AutoResizeTextarea } from '@/components/ui/auto-resize-textarea'; import { Badge } from '@/components/ui/badge'; -import { ArrowLeft, MessageSquare, ChevronLeft, ChevronRight, ListTree, Link2, Table, ExternalLink, PlusCircle, LinkIcon, Building2, Bell, Trash2, Plus, Search, X, Users, User, History, Check, Pencil, PencilOff, Maximize2, Minimize2, Upload, FileText, Download, Clock, Lock, AlertCircle, Shield, UserCheck, UserX, CheckCircle, MapPin, Navigation, FileSignature, EyeOff, Eye, Bot } from 'lucide-react'; +import { ArrowLeft, MessageSquare, ChevronLeft, ChevronRight, ListTree, LinkIcon, Bell, Trash2, Plus, Search, X, Users, User, History, Check, Pencil, Maximize2, Minimize2, FileText, Clock, Lock, AlertCircle, Shield, UserCheck, UserX, CheckCircle, EyeOff, Eye, Bot } from 'lucide-react'; import { Link } from 'wouter'; -import { apiRequest, queryClient, OfflineQueuedError, ConflictError, BadRequestError, taskUpdateRequest } from '@/lib/queryClient'; +import { apiRequest, queryClient, OfflineQueuedError, BadRequestError, taskUpdateRequest } from '@/lib/queryClient'; import { useToast } from '@/hooks/use-toast'; import { cn } from '@/lib/utils'; -import { format } from 'date-fns'; import { formatOrg, parseOrgDateTime } from '@/lib/timezone'; -import { addFileToken, openFilePresigned } from '@/lib/fileUtils'; -import { normalizeSelectOptions, getSelectOptionLabel } from '@/lib/fieldOptions'; import { statusBadgeStyle, getContrastTextColor } from '@/lib/statusColor'; -import { SearchableMultiSelect } from '@/components/fields/SearchableMultiSelect'; -import { fieldControlClass } from '@/components/fields/field-styles'; import TaskChat from '../components/TaskChat'; import { JsComponentRenderer } from '@/components/tabs/JsComponentRenderer'; -import { FileViewerModal, detectViewerType } from '@/components/FileViewerModal'; +import { FieldValueRow, isInteractiveClickTarget, isRadixPortalTarget } from '@/components/task-fields/FieldValueRow'; +import type { FormFieldData, TaskData, TaskFieldValueData } from '@/components/task-fields/FieldValueRow'; +import { parseDetailLayout } from '@/layouts'; +import type { LayoutPresetProps } from '@/layouts/types'; import { RelatedTasksTab } from '@/components/RelatedTasksTab'; import { TaskHistory } from '@/components/TaskHistory'; import TaskRegularTable from '@/components/TaskRegularTable'; -import { SafeHtmlRenderer } from '@/components/SafeHtmlRenderer'; -import GeoField, { type GeoValue } from '@/components/GeoField'; -import GeoNavigationPicker from '@/components/GeoNavigationPicker'; import { DeclarativeTabRenderer } from '@/components/tabs/DeclarativeTabRenderer'; import { ErrorBoundary } from '@/components/ErrorBoundary'; import { useIsMobile } from '@/hooks/use-mobile'; @@ -42,23 +34,11 @@ import { useEvents } from '@/hooks/useEvents'; import { useOfflineSyncContext } from '@/contexts/OfflineSyncContext'; import { useTaskWithCache } from '@/hooks/useTaskWithCache'; import type { CachedTaskDetail } from '@/hooks/useTaskWithCache'; -import { saveTask, getTask, getDirectory, saveDirectory, patchTaskCache } from '@/lib/tasksCache'; +import { saveTask, patchTaskCache } from '@/lib/tasksCache'; import type { TableColumnDef, ReminderRecipient, Task } from '@shared/schema'; -import { evaluateAllFormulas, FORMULA_ERROR, FORMULA_CIRCULAR_ERROR } from '@/lib/formulaEngine'; -import CompanyFieldInput, { type DadataCompany } from '@/components/CompanyFieldInput'; -import ContractNumberField from '@/components/ContractNumberField'; -import ContractNumberDisplay from '@/components/ContractNumberDisplay'; -import TableFieldSelect from '@/components/TableFieldSelect'; -import TableFieldMultiSelect from '@/components/TableFieldMultiSelect'; -import TableFieldWithSubdirectory from '@/components/TableFieldWithSubdirectory'; -import ContactField from '@/components/ContactField'; -import { UserRoleRenderer, UserRoleEditor } from '@/components/fields'; -import { TaskFieldSelect } from '@/components/task-form-fields'; -import { TaskDocumentButton } from '@/components/tasks/TaskDocumentButton'; +import { evaluateAllFormulas } from '@/lib/formulaEngine'; import { TaskQrButton } from '@/components/TaskQrButton'; -import { DocumentTemplateField } from '@/components/fields/DocumentTemplateField'; import { - isFieldValueEmpty, computeFieldState, buildRequiredConditionContext, buildClientFieldValues, @@ -120,71 +100,6 @@ interface FormTab { tableColumns?: TableColumnDef[]; } -interface FormFieldData { - id: number; - name: string; - code: string; - type: string; - isRequired: boolean; - placeholder?: string; - options?: unknown; - defaultValue?: string; - validationRules?: any; - requiredRules?: { - enabled?: boolean; - statusId?: number | "__creation__" | null; - logic?: "and" | "or"; - conditions?: Array<{ fieldCode: string; operator?: "contains" | "empty" | "filled"; value?: string }>; - } | null; - immutableRules?: { - enabled?: boolean; - statusId?: number | "__creation__" | null; - logic?: "and" | "or"; - conditions?: Array<{ fieldCode: string; operator?: "contains" | "empty" | "filled"; value?: string }>; - } | null; - visibilityRules?: { - enabled?: boolean; - statusId?: number | "__creation__" | null; - logic?: "and" | "or"; - conditions?: Array<{ fieldCode: string; operator?: "contains" | "empty" | "filled"; value?: string }>; - } | null; - tabId?: number | null; - linkedFormId?: number | null; - taskRelationType?: 'parent' | 'child' | null; - buttonActionType?: 'open_url' | 'create_task' | 'generate_document' | null; - buttonUrl?: string | null; - buttonFormId?: number | null; - companyAutofill?: Record; - maxFileCount?: number | null; - maxFileSizeMB?: number | null; - autoProlongation?: boolean; - autoProlongationLabel?: string; - allowMultiple?: boolean; -} - -interface TaskData { - id: number; - title: string; - description?: string; - currentStatusId: number; - assignedTo?: number; - createdBy: number; - createdAt: string; - updatedAt: string; - completedAt?: string; - isCompleted?: boolean; - dueDate?: string; - parentTaskId?: number | null; - depth?: number; -} - -interface TaskFieldValueData { - id: number; - taskId: number; - fieldId: number; - value: string | number | boolean | null; -} - interface TaskFieldValuesResponse { fieldValues: TaskFieldValueData[]; } @@ -199,1464 +114,6 @@ interface FormStatus { isFinal?: boolean; } -// Компонент для отображения значения связанной задачи -function LinkedTaskDisplay({ linkedFormId, taskId, relationType }: { linkedFormId: number; taskId: string | number; relationType?: 'parent' | 'child' | null }) { - const { isOnline } = useOfflineSyncContext(); - const [offlineTask, setOfflineTask] = useState<{ id: number; title: string } | null>(null); - - const { data: tasksData, isLoading } = useQuery<{ tasks: Array<{ id: number; title: string }> }>({ - queryKey: ['/api/forms', linkedFormId, 'tasks'], - enabled: !!linkedFormId && !!taskId && isOnline, - staleTime: 5 * 60 * 1000, - }); - - // Offline fallback: resolve task title from IDB task-cache - useEffect(() => { - const numId = Number(taskId); - if (!isOnline && !tasksData && numId) { - getTask(numId).then(cached => { - if (cached?.data && typeof cached.data === 'object') { - const d = cached.data as { task?: { id: number; title: string } }; - if (d.task) setOfflineTask({ id: d.task.id, title: d.task.title }); - } - }); - } - if (isOnline) setOfflineTask(null); - }, [isOnline, tasksData, taskId]); - - if (isLoading && isOnline) return Загрузка...; - - const liveTask = tasksData?.tasks?.find(t => t.id.toString() === taskId?.toString()); - const task = liveTask ?? offlineTask; - if (!task) return Задача не найдена; - - return ( - - - - - {task.title} - - - {relationType === 'parent' && ( - - Родитель - - )} - {relationType === 'child' && ( - - Дочерняя - - )} - - ); -} - -// Компонент для отображения значения из справочника -type DirectoryData = { table: { rows: Array<{ id: number; values: string[] }>; columns: Array<{ name: string }> } }; - -function TableValueDisplay({ tableId, displayColumn, rowId }: { tableId: number; displayColumn?: string; rowId: string | number | Array }) { - const { isOnline } = useOfflineSyncContext(); - const [offlineData, setOfflineData] = useState(null); - - const isArray = Array.isArray(rowId); - const rowIds = isArray ? rowId.map(String) : [String(rowId)]; - - const { data: tableData, isLoading } = useQuery({ - queryKey: ['/api/directories', tableId], - enabled: !!tableId && rowIds.length > 0 && isOnline, - staleTime: 5 * 60 * 1000, - }); - - // Persist to IDB whenever live data arrives - useEffect(() => { - if (tableData && tableId) { - saveDirectory(tableId, tableData).catch(() => {}); - } - }, [tableData, tableId]); - - // Load from IDB when offline and live data is unavailable - useEffect(() => { - if (!isOnline && !tableData && tableId && rowIds.length > 0) { - getDirectory(tableId).then(cached => { - if (cached) setOfflineData(cached.data as DirectoryData); - }).catch(() => {}); - } - }, [isOnline, tableData, tableId, rowIds]); - - const effectiveData = tableData ?? offlineData; - - if (isLoading && !offlineData) return Загрузка...; - - const table = effectiveData?.table; - if (!table) return Таблица не найдена; - - let displayColumnIndex = table.columns?.findIndex(c => c.name === displayColumn); - if (displayColumnIndex === undefined || displayColumnIndex < 0) { - displayColumnIndex = 0; - } - - const rows = table.rows || []; - const labels = rowIds - .map((id) => { - const row = rows.find((r) => r.id.toString() === id); - if (!row) return null; - return row.values[displayColumnIndex] || row.values[0] || `#${id}`; - }) - .filter(Boolean); - - if (labels.length === 0) return Запись не найдена; - - return ( - - - {labels.join(', ')} - - ); -} - -// Компонент для рендеринга кнопки с действием -function ButtonField({ - field, - taskId, - allFieldValues, - allFields, -}: { - field: FormFieldData; - taskId?: number; - allFieldValues?: Record; - allFields?: FormFieldData[]; -}) { - const [createDialogOpen, setCreateDialogOpen] = useState(false); - const { toast } = useToast(); - - const buttonOptions = (field.options || {}) as { - documentTemplateId?: number; - generateFormat?: 'docx' | 'pdf' | 'both'; - generateReturnTo?: string; - relationType?: 'none' | 'parent' | 'child'; - copyFields?: Array<{ sourceFieldId: number; targetFieldId: number }>; - }; - - const buildInitialValues = (): Record | undefined => { - const copyFields = buttonOptions.copyFields; - if (!copyFields || copyFields.length === 0 || !allFieldValues || !allFields) return undefined; - - const fieldById = new Map(allFields.map(f => [f.id, f])); - const initialValues: Record = {}; - - for (const mapping of copyFields) { - const sourceField = fieldById.get(mapping.sourceFieldId); - if (!sourceField) continue; - const value = allFieldValues[sourceField.code]; - if (value === undefined || value === null || value === '') continue; - initialValues[`customField_${mapping.targetFieldId}`] = value; - } - - return Object.keys(initialValues).length > 0 ? initialValues : undefined; - }; - - const handleCreated = async (createdTask: { id: number; title: string }) => { - const relationType = buttonOptions.relationType; - if (!taskId || !relationType || relationType === 'none') return; - - try { - if (relationType === 'parent') { - // Новая задача — родительская для текущей - await apiRequest('POST', `/api/tasks/${createdTask.id}/relations`, { - relatedTaskId: taskId, - type: 'child', - }); - } else if (relationType === 'child') { - // Новая задача — дочерняя для текущей - await apiRequest('POST', `/api/tasks/${taskId}/relations`, { - relatedTaskId: createdTask.id, - type: 'child', - }); - } - queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'related'] }); - queryClient.invalidateQueries({ queryKey: ['/api/tasks', createdTask.id, 'related'] }); - } catch (error: any) { - toast({ - title: 'Связь не создана', - description: error.message || 'Не удалось связать задачи', - variant: 'destructive', - }); - } - }; - - const handleButtonClick = () => { - if (field.buttonActionType === 'open_url' && field.buttonUrl) { - window.open(field.buttonUrl, '_blank', 'noopener,noreferrer'); - } else if (field.buttonActionType === 'create_task' && field.buttonFormId) { - setCreateDialogOpen(true); - } else if (field.buttonActionType === 'generate_document' && taskId) { - const templateId = buttonOptions.documentTemplateId; - if (!templateId) return; - const format = buttonOptions.generateFormat || 'both'; - const returnTo = buttonOptions.generateReturnTo || window.location.href; - const url = `/api/documents/generate-link?templateId=${templateId}&taskId=${taskId}&format=${format}&returnTo=${encodeURIComponent(returnTo)}`; - window.location.href = url; - } - }; - - const isConfigured = field.buttonActionType && ( - (field.buttonActionType === 'open_url' && field.buttonUrl) || - (field.buttonActionType === 'create_task' && field.buttonFormId) || - (field.buttonActionType === 'generate_document' && taskId && buttonOptions.documentTemplateId) - ); - - return ( -
- - {field.buttonActionType === 'create_task' && field.buttonFormId && ( - - )} -
- ); -} - -// Типы полей, поддерживающие inline-редактирование -const INLINE_EDITABLE_TYPES = new Set(['text', 'number', 'textarea', 'rich-text', 'date', 'datetime', 'select', 'radio-group', 'checkbox', 'toggle', 'user', 'file', 'company', 'geo', 'contract-number', 'table', 'checklist', 'contact', 'task']); - -interface InlineUserOption { - id: number; - fullName?: string | null; - firstName?: string | null; - lastName?: string | null; - middleName?: string | null; - email: string; -} - -// Вспомогательная функция: форматирование размера файла -function formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} Б`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`; - return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`; -} - -type FileInfo = { url: string; name: string; size?: number }; - -// Безопасное преобразование значения в строку (защита от null/undefined/объектов) -function safeString(value: unknown): string { - if (value === null || value === undefined) return ''; - if (typeof value === 'string') return value; - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -// Парсим значение поля «файл» в массив FileInfo с поддержкой старых форматов -function parseFileValue(initialValue: unknown): FileInfo[] { - if (!initialValue) return []; - - // Новый формат: массив объектов - if (Array.isArray(initialValue)) { - return (initialValue as unknown[]).flatMap((item) => { - if (item && typeof item === 'object') { - const obj = item as Record; - if (typeof obj.url === 'string') { - return [{ url: obj.url, name: String(obj.name || obj.url), size: typeof obj.size === 'number' ? obj.size : undefined }]; - } - } - return []; - }); - } - - // Старый формат: одиночный объект - if (typeof initialValue === 'object') { - const obj = initialValue as Record; - if (typeof obj.url === 'string') { - return [{ url: obj.url, name: String(obj.name || obj.url), size: typeof obj.size === 'number' ? obj.size : undefined }]; - } - return []; - } - - // Строка (очень старый формат или JSON) - if (typeof initialValue === 'string' && initialValue) { - try { - const parsed: unknown = JSON.parse(initialValue); - return parseFileValue(parsed); - } catch { - return [{ url: initialValue, name: initialValue }]; - } - } - - return []; -} - -// Компонент для поля типа «файл» с inline-загрузкой (поддержка нескольких файлов) -function FileFieldRow({ - field, - value: initialValue, - taskId, - formId, -}: { - field: FormFieldData; - value: unknown; - taskId: number; - formId?: number; -}) { - const { toast } = useToast(); - const fileInputRef = useRef(null); - const [isUploading, setIsUploading] = useState(false); - - const files = parseFileValue(initialValue); - const [viewerFile, setViewerFile] = useState<{ url: string; name: string } | null>(null); - - const handleFileChange = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - if (field.maxFileCount != null && files.length >= field.maxFileCount) { - toast({ - title: 'Превышен лимит файлов', - description: `Максимальное количество файлов для этого поля: ${field.maxFileCount}`, - variant: 'destructive', - }); - if (fileInputRef.current) fileInputRef.current.value = ''; - return; - } - - if (field.maxFileSizeMB != null) { - const currentTotalBytes = files.reduce((sum, f) => sum + (f.size || 0), 0); - const limitBytes = field.maxFileSizeMB * 1024 * 1024; - if (currentTotalBytes + file.size > limitBytes) { - toast({ - title: 'Превышен лимит размера', - description: `Суммарный размер файлов не должен превышать ${field.maxFileSizeMB} МБ`, - variant: 'destructive', - }); - if (fileInputRef.current) fileInputRef.current.value = ''; - return; - } - } - - setIsUploading(true); - try { - const formData = new FormData(); - formData.append('file', file); - - const uploadUrl = `/api/upload?fieldId=${field.id}&taskId=${taskId}`; - const uploadRes = await fetch(uploadUrl, { - method: 'POST', - credentials: 'include', - body: formData, - }); - if (!uploadRes.ok) { - const errData = await uploadRes.json().catch(() => ({})); - throw new Error(errData.error || 'Ошибка загрузки файла'); - } - const uploaded = await uploadRes.json(); - - const newFiles = [...files, { url: uploaded.url, name: uploaded.name, size: uploaded.size }]; - await apiRequest('PATCH', `/api/tasks/${taskId}/field-values/${field.id}`, { - value: newFiles, - }); - queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] }); - queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'audit-log'] }); - if (formId) queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'tasks', 'with-fields'] }); - toast({ title: 'Файл загружен', description: uploaded.name }); - } catch (err: unknown) { - if (err instanceof OfflineQueuedError) return; - const msg = err instanceof Error ? err.message : 'Не удалось загрузить файл. Проверьте соединение и попробуйте ещё раз.'; - toast({ title: 'Ошибка загрузки', description: msg, variant: 'destructive' }); - } finally { - setIsUploading(false); - if (fileInputRef.current) fileInputRef.current.value = ''; - } - }; - - const handleRemoveFile = async (index: number) => { - try { - const newFiles = files.filter((_, i) => i !== index); - await apiRequest('PATCH', `/api/tasks/${taskId}/field-values/${field.id}`, { - value: newFiles.length > 0 ? newFiles : null, - }); - queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] }); - queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'audit-log'] }); - if (formId) queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'tasks', 'with-fields'] }); - toast({ title: 'Файл удалён' }); - } catch (err: unknown) { - if (err instanceof OfflineQueuedError) return; - const msg = err instanceof Error ? err.message : 'Не удалось удалить файл. Проверьте соединение и попробуйте ещё раз.'; - toast({ title: 'Ошибка удаления', description: msg, variant: 'destructive' }); - } - }; - - return ( -
-
- -
-
- {field.name}:{' '} - {field.maxFileCount != null && ( - ({files.length}/{field.maxFileCount}) - )} - {isUploading ? ( - Загрузка... - ) : files.length > 0 ? ( - <> -
- {files.map((fileInfo, idx) => { - const vType = detectViewerType(fileInfo.url, fileInfo.name); - const canPreview = vType === 'pdf' || vType === 'word' || vType === 'excel' || vType === 'powerpoint' || vType === 'video' || vType === 'audio' || vType === 'image'; - return ( - - {canPreview ? ( - - ) : ( - { e.preventDefault(); openFilePresigned(fileInfo.url); }} - target="_blank" - rel="noopener noreferrer" - className="inline-flex items-center gap-1 text-sm text-primary hover:underline min-w-0" - data-testid={`file-link-${field.code}-${idx}`} - > - - {fileInfo.name} - {fileInfo.size !== undefined && ( - ({formatFileSize(fileInfo.size)}) - )} - - - )} - - - ); - })} -
- - ) : ( - Нет файла - )} -
- - {viewerFile && ( - setViewerFile(null)} - /> - )} -
- ); -} - -// Вспомогательная функция: получить значение из данных DaData по ключу -function getDadataFieldValue(key: string, company: DadataCompany): string { - switch (key) { - case 'name.full_with_opf': return company.data.name.full_with_opf || ''; - case 'name.short_with_opf': return company.data.name.short_with_opf || ''; - case 'inn': return company.data.inn || ''; - case 'kpp': return company.data.kpp || ''; - case 'ogrn': return company.data.ogrn || ''; - case 'management.name': return company.data.management?.name || ''; - case 'management.post': return company.data.management?.post || ''; - default: return ''; - } -} - -// Сравнение значений полей: null/undefined/''/false считаем пустыми и равными друг другу -function fieldValuesEqual(a: unknown, b: unknown): boolean { - if (a === b) return true; - const isEmpty = (v: unknown) => v === null || v === undefined || v === '' || v === false; - if (isEmpty(a) && isEmpty(b)) return true; - return JSON.stringify(a) === JSON.stringify(b); -} - -// Клик по интерактивному элементу внутри read-view (ссылка, кнопка, контрол) — не открываем редактор -function isInteractiveClickTarget(target: EventTarget | null): boolean { - if (!(target instanceof Element)) return false; - return !!target.closest('a, button, input, select, textarea, [role="button"], [role="checkbox"], [role="listbox"]'); -} - -// Клик пришёл из портала Radix (выпадающие списки редакторов) — не считаем «кликом снаружи» -function isRadixPortalTarget(target: EventTarget | null): boolean { - if (!(target instanceof Element)) return false; - return !!target.closest('[data-radix-popper-content-wrapper], [role="listbox"], [data-radix-select-viewport], [data-radix-menu-content], [data-radix-popover-content]'); -} - -// Компонент для отображения одного поля с per-field inline-редактированием: -// клик по значению открывает редактор только этой строки, сохранение — PATCH одного поля. -function FieldValueRow({ - field, - value: initialValue, - task, - taskId, - formId, - users, - roles, - allFieldValues, - allFields, - statuses, - isPending, - readOnly, - errorMessage, - onErrorClear, -}: { - field: FormFieldData; - value: string | number | boolean | null | undefined; - task?: TaskData; - taskId: number; - formId?: number; - users: InlineUserOption[]; - roles?: Array<{ id: number; name: string }>; - allFieldValues?: Record; - allFields?: FormFieldData[]; - statuses?: Array<{ id: number; position: number; isFinal?: boolean | null }>; - isPending?: (url: string) => boolean; - readOnly?: boolean; - errorMessage?: string; - onErrorClear?: () => void; -}) { - const { toast } = useToast(); - - // Вычисляем условное состояние поля (required/immutable/visible) для отображения меток и валидации - const fieldState = useMemo(() => { - if (!allFields) return { required: false, immutable: false, visible: true }; - const fieldValuesArray = allFields.map((f) => ({ fieldId: f.id, value: allFieldValues?.[f.code] })); - const allConditionFields = getAllConditionFields(allFields as ClientFormField[]); - const context = buildRequiredConditionContext(users as any, roles || [], false, statuses); - const taskForConditions = task ?? null; - return computeFieldState( - field as ClientFormField, - taskForConditions, - buildClientFieldValues(taskForConditions, fieldValuesArray, statuses), - allConditionFields, - context - ); - }, [field, task, allFieldValues, allFields, users, roles, statuses]); - - const isEditable = INLINE_EDITABLE_TYPES.has(field.type); - - const [navGeo, setNavGeo] = useState(null); - const wrapperRef = useRef(null); - - // Двойное касание на мобильных — вход в редактирование (как до per-field рефакторинга) - const lastTapRef = useRef(0); - const handleDoubleTap = (e: React.TouchEvent) => { - if (!canEdit) return; - if (isInteractiveClickTarget(e.target)) return; - const now = Date.now(); - if (now - lastTapRef.current < 300) { - e.stopPropagation(); - enterEdit(); - } - lastTapRef.current = now; - }; - - // Per-field режим редактирования: локальное состояние только этой строки - const [isEditing, setIsEditing] = useState(false); - const [editValue, setEditValue] = useState(initialValue); - const editValueRef = useRef(initialValue); - const [localError, setLocalError] = useState(null); - - // Очередь обновлений autofill-полей, которые нужно сохранить вместе с основным полем - const pendingAutofillRef = useRef>([]); - - // Флаги составных редакторов (сохранение по закрытию — клик вне строки) - const isProlongationDate = (field.type === 'date' || field.type === 'datetime') && !!field.autoProlongation; - const tableOpts = field.type === 'table' && field.options && !Array.isArray(field.options) - ? (field.options as Record) - : {}; - const isTableSubdirectory = field.type === 'table' && !!tableOpts.showSubdirectory; - const isCommitOnClose = - field.type === 'geo' || - field.type === 'contact' || - field.type === 'contract-number' || - field.type === 'company' || - isProlongationDate || - isTableSubdirectory; - - const canEdit = isEditable && !readOnly && !fieldState.immutable; - - // Пока поле не редактируется — синхронизируем локальный черновик со значением с сервера - useEffect(() => { - if (!isEditing) { - editValueRef.current = initialValue; - setEditValue(initialValue); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialValue, isEditing]); - - const updateEditValue = (val: unknown) => { - editValueRef.current = val; - setEditValue(val); - if (localError) setLocalError(null); - }; - - const enterEdit = () => { - if (isEditing || !isEditable || readOnly) return; - if (fieldState.immutable) { - toast({ - title: 'Редактирование недоступно', - description: `Поле «${field.name}» не может быть изменено в текущем статусе`, - variant: 'destructive', - }); - return; - } - editValueRef.current = initialValue; - setEditValue(initialValue); - setLocalError(null); - setIsEditing(true); - }; - - const cancelEdit = () => { - setIsEditing(false); - setLocalError(null); - // Сбрасываем очередь autofill-затирания (company onClear) — иначе она «утечёт» в следующее сохранение - pendingAutofillRef.current = []; - editValueRef.current = initialValue; - setEditValue(initialValue); - }; - - // Сохранение значения: required-валидация прямо в строке, затем PATCH одного поля - const commitValue = (val: unknown, opts?: { close?: boolean }) => { - const close = opts?.close !== false; - if (fieldState.required && isFieldValueEmpty(val, field.type)) { - setLocalError(`Поле «${field.name}» обязательно к заполнению`); - return; - } - setLocalError(null); - if (!fieldValuesEqual(val, initialValue)) { - saveValue(val as string | number | boolean | Record | null | undefined); - } else { - // Значение не изменилось — сохранение пропускаем, autofill-очередь затираем - pendingAutofillRef.current = []; - } - if (close) setIsEditing(false); - }; - - // Клик вне строки закрывает редактор: составные редакторы сохраняются, остальные отменяются - useEffect(() => { - if (!isEditing) return; - const handleOutsideClick = (e: MouseEvent) => { - const target = e.target; - if (wrapperRef.current?.contains(target as Node)) return; - if (isRadixPortalTarget(target)) return; - if (isCommitOnClose) { - commitValue(editValueRef.current); - } else { - cancelEdit(); - } - }; - document.addEventListener('click', handleOutsideClick); - return () => document.removeEventListener('click', handleOutsideClick); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isEditing, isCommitOnClose]); - - // Esc — отмена редактирования без сохранения (для редакторов без собственной обработки) - useEffect(() => { - if (!isEditing) return; - const handleEsc = (e: KeyboardEvent) => { - if (e.key === 'Escape') cancelEdit(); - }; - document.addEventListener('keydown', handleEsc); - return () => document.removeEventListener('keydown', handleEsc); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isEditing]); - - const saveValue = async (val: string | number | boolean | Record | null | undefined) => { - // Optimistic-обновление кэша детали задачи: read-view меняется мгновенно, без мерцания - const prevDetail = queryClient.getQueryData(['/api/tasks', taskId, 'detail']); - if (prevDetail) { - const prevFieldValues = prevDetail.fieldValues || []; - const idx = prevFieldValues.findIndex(fv => fv.fieldId === field.id); - const nextEntry: TaskFieldValueData = idx >= 0 - ? { ...prevFieldValues[idx], value: val as string | number | boolean | null } - : { id: -Date.now(), taskId, fieldId: field.id, value: val as string | number | boolean | null }; - const nextFieldValues = idx >= 0 - ? prevFieldValues.map((fv, i) => (i === idx ? nextEntry : fv)) - : [...prevFieldValues, nextEntry]; - const merged: TaskDetailResponse = { ...prevDetail, fieldValues: nextFieldValues }; - queryClient.setQueryData(['/api/tasks', taskId, 'detail'], merged); - saveTask(taskId, merged).catch(() => {}); - } - try { - await apiRequest('PATCH', `/api/tasks/${taskId}/field-values/${field.id}`, { value: val }); - // Сохраняем pending autofill-поля (при выборе/очистке компании) - const pendingUpdates = pendingAutofillRef.current; - if (pendingUpdates.length > 0) { - await Promise.all( - pendingUpdates.map(({ fieldId, value }) => - apiRequest('PATCH', `/api/tasks/${taskId}/field-values/${fieldId}`, { value }) - ) - ); - pendingAutofillRef.current = []; - } - queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] }); - queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'audit-log'] }); - onErrorClear?.(); - } catch (err: unknown) { - if (err instanceof OfflineQueuedError) { - // Основное поле поставлено в офлайн-очередь — ставим туда же autofill-поля - const pendingUpdates = pendingAutofillRef.current; - pendingAutofillRef.current = []; - if (pendingUpdates.length > 0) { - await Promise.allSettled( - pendingUpdates.map(({ fieldId, value }) => - apiRequest('PATCH', `/api/tasks/${taskId}/field-values/${fieldId}`, { value }) - ) - ); - } - return; - } - // Ошибка сохранения — откатываем optimistic-значение перезапросом детали - queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] }); - const msg = err instanceof Error ? err.message : 'Не удалось сохранить. Проверьте соединение и попробуйте ещё раз.'; - toast({ title: 'Ошибка сохранения', description: msg, variant: 'destructive' }); - } - }; - - // Отображение читаемого значения (без редактора) - const renderReadValue = () => { - // Формула — вычисляемое read-only поле с жёлтым фоном - if (field.type === 'formula') { - // The resolved value is already pre-computed (with topological ordering) in allFieldValues - const precomputed = allFieldValues ? allFieldValues[field.code] : undefined; - const result: string | number = (precomputed !== null && precomputed !== undefined) - ? (precomputed as string | number) - : ''; - const isCircular = result === FORMULA_CIRCULAR_ERROR; - const isError = result === FORMULA_ERROR || isCircular; - return ( - - {isError ? ( - - {String(result)} - - ) : result === '' ? ( - — - ) : ( - {String(result)} - )} - - ); - } - - if (initialValue === null || initialValue === undefined || initialValue === '') { - return —; - } - if (field.type === 'task' && field.linkedFormId) { - const taskIdVal = typeof initialValue === 'boolean' ? String(initialValue) : initialValue; - return ( - - - - ); - } - if (field.type === 'table' && field.options) { - const opts = field.options as { tableId?: number; displayColumn?: string }; - if (opts.tableId) { - const rowIdVal = typeof initialValue === 'boolean' ? String(initialValue) : initialValue; - return ( - - - - ); - } - } - if (field.type === 'company') { - return ( - - - {initialValue.toString()} - - ); - } - if (field.type === 'geo' && initialValue) { - let geo: any; - try { - geo = typeof initialValue === 'string' ? JSON.parse(initialValue) : initialValue; - } catch { - geo = null; - } - const display = geo?.address || `${geo?.lat || ''}, ${geo?.lng || ''}` || null; - if (!display) return —; - return ( - { e.preventDefault(); setNavGeo(geo); }} - className="text-primary hover:underline inline-flex items-center gap-1 cursor-pointer" - > - - {display} - - ); - } - if (field.type === 'checkbox' || field.type === 'toggle') { - const checked = initialValue === true || initialValue === 'true'; - return ( - {checked ? 'Да' : 'Нет'} - ); - } - if (field.type === 'checklist' && Array.isArray(initialValue)) { - const items = Array.isArray(field.options) ? field.options : []; - const checkedCount = initialValue.filter((v: any) => v?.checked).length; - const totalCount = items.length; - return ( - - Выполнено {checkedCount} из {totalCount} - - ); - } - if (field.type === 'date' || field.type === 'datetime') { - if (initialValue === '__auto_prolongation__') { - return {field.autoProlongationLabel || 'Автопролонгация'}; - } - if (initialValue) { - try { - const date = new Date(initialValue.toString()); - if (!isNaN(date.getTime())) { - return {date.toLocaleDateString('ru-RU')}; - } - } catch (_e) { /* показываем как есть */ } - } - } - if (field.type === 'user') { - return ( - - - - ); - } - if (field.type === 'select' || field.type === 'radio-group') { - return {getSelectOptionLabel(field.options, initialValue) || initialValue.toString()}; - } - if (field.type === 'textarea' || field.type === 'rich-text') { - return ( -
- -
- ); - } - if (field.type === 'geo') { - if (!initialValue) return —; - const geo = initialValue as GeoValue; - const lat = typeof geo.lat === 'string' ? parseFloat(geo.lat) : geo.lat; - const lng = typeof geo.lng === 'string' ? parseFloat(geo.lng) : geo.lng; - const display = geo.address || `${lat}, ${lng}`; - return ( - { e.preventDefault(); setNavGeo(geo); }} - className="inline-flex items-center gap-1 text-primary hover:underline cursor-pointer" - data-testid={`text-custom-field-${field.code}`} - > - - {display} - - ); - } - if (field.type === 'contract-number' && initialValue) { - return ( - - - - ); - } - if (field.type === 'contact' && initialValue) { - let parsed: any; - try { - parsed = typeof initialValue === 'string' ? JSON.parse(initialValue) : initialValue; - } catch { - parsed = null; - } - if (!parsed?.name && !parsed?.position && !parsed?.phones?.length && !parsed?.emails?.length) { - return —; - } - const line1 = [parsed.name, parsed.position].filter(Boolean).join(' '); - const line2Parts: string[] = []; - if (parsed.phones?.length) line2Parts.push(parsed.phones.join(', ')); - if (parsed.emails?.length) line2Parts.push(parsed.emails.join(', ')); - const line2 = line2Parts.join(' | '); - return ( -
- {line1 && {line1}} - {line2 && {line2}} -
- ); - } - return {safeString(initialValue)}; - }; - - // Рендер редактора по типу поля (per-field: сохранение по blur/Enter/выбору/закрытию) - const renderEditor = () => { - // Единый стиль контрола карточки задачи (detail-вариант дизайн-системы полей) - const inputClass = cn( - fieldControlClass('detail'), - 'leading-6', - effectiveError && 'border-destructive ring-destructive' - ); - const textareaClass = cn( - 'border border-input rounded px-1.5 py-0.5 text-base md:text-[13px] bg-background focus:outline-none focus:ring-1 focus:ring-ring w-auto min-w-[200px] max-w-[420px]', - effectiveError && 'border-destructive ring-destructive' - ); - // Текст и число: сохранение по blur и Enter, Esc — отмена - if (field.type === 'text' || field.type === 'number') { - return ( - updateEditValue(field.type === 'number' ? e.target.value.replace(/\s/g, '') : e.target.value)} - onClick={e => e.stopPropagation()} - autoFocus - aria-label={field.name} - aria-invalid={!!effectiveError} - aria-describedby={effectiveError ? fieldErrorId : undefined} - onBlur={() => commitValue(editValueRef.current)} - onKeyDown={e => { - if (e.key === 'Enter') { e.preventDefault(); commitValue(editValueRef.current); } - if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); } - }} - /> - ); - } - // Многострочный текст: сохранение по blur, Esc — отмена (Enter — новая строка) - if (field.type === 'textarea' || field.type === 'rich-text') { - return ( - updateEditValue(e.target.value)} - onClick={e => e.stopPropagation()} - autoFocus - aria-label={field.name} - aria-invalid={!!effectiveError} - aria-describedby={effectiveError ? fieldErrorId : undefined} - placeholder={field.type === 'rich-text' ? 'HTML: жирный, курсив,
  • список
' : undefined} - onBlur={() => commitValue(editValueRef.current)} - onKeyDown={e => { - if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); } - }} - /> - ); - } - // Дата/время без автопролонгации: сохранение по выбору (change), ✕ — очистить, Esc — отмена - if ((field.type === 'date' || field.type === 'datetime') && !field.autoProlongation) { - const isDateTime = field.type === 'datetime'; - const completeRe = isDateTime ? /^.{16}$/ : /^\d{4}-\d{2}-\d{2}$/; - return ( -
- { - const v = e.target.value; - updateEditValue(v); - if (v === '' || completeRe.test(v)) commitValue(v); - }} - onClick={e => e.stopPropagation()} - autoFocus - aria-label={field.name} - aria-invalid={!!effectiveError} - aria-describedby={effectiveError ? fieldErrorId : undefined} - onKeyDown={e => { - if (e.key === 'Enter') { e.preventDefault(); commitValue(editValueRef.current); } - if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); } - }} - /> - {editValue ? ( - - ) : null} -
- ); - } - // Дата/время с автопролонгацией: составной редактор, сохранение по закрытию (клик вне / Enter) - if (field.type === 'date' || field.type === 'datetime') { - const isProlongation = editValue === '__auto_prolongation__'; - return ( -
e.stopPropagation()}> - updateEditValue(e.target.value)} - autoFocus - disabled={isProlongation} - aria-label={field.name} - aria-invalid={!!effectiveError} - aria-describedby={effectiveError ? fieldErrorId : undefined} - onKeyDown={e => { - if (e.key === 'Enter') { e.preventDefault(); commitValue(editValueRef.current); } - if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); } - }} - /> - -
- ); - } - if (field.type === 'select' || field.type === 'radio-group') { - const opts = normalizeSelectOptions(field.options); - // Мультивыбор: сохранение по каждому переключению, выход — клик вне / Esc - if (field.allowMultiple) { - const selected = Array.isArray(editValue) - ? new Set(editValue.map((v: unknown) => String(v))) - : editValue != null && editValue !== '' - ? new Set([String(editValue)]) - : new Set(); - return ( -
e.stopPropagation()}> - ({ value: o.value, label: o.label }))} - selected={selected} - onChange={(next) => { - const arr = Array.from(next); - updateEditValue(arr); - commitValue(arr, { close: false }); - }} - /> -
- ); - } - // Одиночный выбор: сохранение сразу по выбору - return ( - - ); - } - // Чек-лист: сохранение по каждому переключению, выход — клик вне / Esc - if (field.type === 'checklist') { - const items = Array.isArray(field.options) ? field.options : []; - const valueArr = Array.isArray(editValue) ? (editValue as Array<{ itemId?: string; id?: string; checked?: boolean }>) : []; - return ( -
e.stopPropagation()}> - {items.map((item: unknown, idx: number) => { - const it = item as { id?: string; value?: string; label?: string; name?: string; period?: string }; - const itemId = it.id || it.value || String(idx); - const itemLabel = it.label || it.name || String(item); - const checkedItem = valueArr.find((v) => (v.itemId || v.id) === itemId); - const isChecked = !!checkedItem?.checked; - return ( - - ); - })} -
- ); - } - // Пользователь/роль: сохранение сразу по выбору (radix Select) - if (field.type === 'user') { - return ( -
e.stopPropagation()}> - commitValue(val)} - users={users} - roles={roles} - autoOpen - /> -
- ); - } - // Гео: составной редактор, сохранение по закрытию - if (field.type === 'geo') { - return ( -
e.stopPropagation()}> - updateEditValue(val)} - /> -
- ); - } - // Компания: выбор подсказки сохраняет сразу (с autofill), закрытие — сохраняет введённый текст - if (field.type === 'company') { - return ( -
e.stopPropagation()}> - updateEditValue(val)} - onSelect={company => { - updateEditValue(company.value); - if (field.companyAutofill && allFields) { - pendingAutofillRef.current = Object.entries(field.companyAutofill) - .filter(([, code]) => code && code !== 'none') - .flatMap(([dadataKey, code]) => { - const targetField = allFields.find(f => f.code === code); - return targetField ? [{ fieldId: targetField.id, value: getDadataFieldValue(dadataKey, company) }] : []; - }); - } - commitValue(company.value); - }} - onClear={() => { - if (field.companyAutofill && allFields) { - pendingAutofillRef.current = Object.values(field.companyAutofill) - .filter(code => code && code !== 'none') - .flatMap(code => { - const targetField = allFields.find(f => f.code === code); - return targetField ? [{ fieldId: targetField.id, value: '' }] : []; - }); - } - }} - /> -
- ); - } - // Номер договора: составной редактор, сохранение по закрытию - if (field.type === 'contract-number') { - const cnConfig = (field.options as any)?.contractNumber; - return ( -
e.stopPropagation()}> - updateEditValue(JSON.stringify(val))} - config={cnConfig} - formId={formId} - fieldId={field.id} - compact - /> -
- ); - } - if (field.type === 'table') { - const showSubdirectory = isTableSubdirectory; - // Подсправочник: составной редактор, сохранение по закрытию - if (showSubdirectory) { - return ( -
e.stopPropagation()}> - updateEditValue(val)} - allowMultiple={field.allowMultiple || false} - size="detail" - /> -
- ); - } - // Мультивыбор строк: сохранение по каждому переключению - if (field.allowMultiple) { - return ( -
e.stopPropagation()}> - { - updateEditValue(val); - commitValue(val, { close: false }); - }} - /> -
- ); - } - // Одиночный выбор строки: сохранение сразу по выбору - return ( -
e.stopPropagation()}> - commitValue(val)} - /> -
- ); - } - // Контакт: составной редактор, сохранение по закрытию - if (field.type === 'contact') { - return ( -
e.stopPropagation()}> - updateEditValue(JSON.stringify(val))} - /> -
- ); - } - // Связанная задача: сохранение сразу по выбору - if (field.type === 'task' && field.linkedFormId) { - return ( -
e.stopPropagation()}> - commitValue(val)} - showCreateButton={false} - size="detail" - /> -
- ); - } - return null; - }; - - // Кнопка — всегда read-only (своя логика действий) - if (field.type === 'button') { - return ; - } - - // Файл — своя логика загрузки - if (field.type === 'file') { - return ; - } - - // Шаблон документа — выбор шаблона и генерация - if (field.type === 'document_template') { - return ( - saveValue(val)} - readOnly={readOnly} - /> - ); - } - - const fieldUrl = `/api/tasks/${taskId}/field-values/${field.id}`; - const isFieldPending = isPending ? isPending(fieldUrl) : false; - const effectiveError = errorMessage || localError; - const fieldErrorId = `fv-error-${field.id}`; - // checkbox/toggle — мгновенное переключение по клику, без входа в режим редактирования - const isInstantToggle = field.type === 'checkbox' || field.type === 'toggle'; - const editorNode = isEditing && !isInstantToggle ? renderEditor() : null; - - return ( - <> -
- {/* Левая колонка: индикаторы обязательности / неизменяемости / карандаш */} -
- {isFieldPending ? ( - - - - ) : fieldState.immutable ? ( - - ) : fieldState.required ? ( - - * - - ) : ( - /* Карандаш: на мобильном всегда виден, на десктопе — по hover строки или фокусу */ - isEditable && !readOnly && !isEditing && ( - - ) - )} -
- - {/* Контент поля: read-view и редактор занимают один и тот же слот (без оверлеев) */} -
-
- - {field.name}: - - {isInstantToggle ? ( - - ) : isEditing && editorNode != null ? ( -
- {editorNode} -
- ) : ( -
{ - if (!canEdit) return; - if (isInteractiveClickTarget(e.target)) return; - e.stopPropagation(); - enterEdit(); - }} - onTouchEnd={handleDoubleTap} - > - {renderReadValue()} -
- )} -
- {isFieldPending && ( - - - ожидает - - )} - {effectiveError && ( - {effectiveError} - )} -
-
- { if (!open) setNavGeo(null); }} - /> - - ); -} - interface FormTransition { id: number; fromStatusId: number; @@ -1674,6 +131,15 @@ interface FormTransition { resolvedAssigneeUserIds?: number[]; } +// Запись об исполнителе задачи (task_assignees) — передаётся в слоты кастомного layout +interface TaskAssigneeEntry { + id: number; + userId: number; + delegatedFromUserId?: number | null; + pendingReview?: boolean; + user: { id: number; firstName: string | null; lastName: string | null; email: string }; +} + interface UserSummary { id: number; email: string; @@ -2436,7 +902,7 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde )} {/* Assignee + Roles */} {!hidden.has('assignedTo') && ( -
+
{!readOnly ? leftSlot(() => { setAssigneeDropdownOpen(v => !v); setAssigneeSearch(''); }) : emptySlot} @@ -2689,7 +1155,7 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde ); return (
{emptySlot} Срок: @@ -2715,7 +1181,7 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde {/* Created (read-only) */} {!hidden.has('createdAt') && ( -
+
{emptySlot} Создана: @@ -2728,7 +1194,7 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde {/* Completed at (read-only, shown only when task is in final status) */} {isTaskFinal && !hidden.has('completedAt') && ( -
+
{emptySlot} Завершена: @@ -2741,7 +1207,7 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde {/* Creator (read-only) */} {!hidden.has('createdBy') && task.createdBy && ( -
+
{emptySlot} Автор: {getUserName(task.createdBy)} @@ -2859,6 +1325,9 @@ interface TaskContentProps { getUserName: (id: number) => string; systemFieldsTabId?: number | null; hasTabs: boolean; + // Слоты расширения для кастомного layout/пресета: переходы статусов и исполнители + transitions: FormTransition[]; + assignees: TaskAssigneeEntry[]; } function TaskContent({ @@ -2886,6 +1355,8 @@ function TaskContent({ getUserName, systemFieldsTabId, hasTabs, + transitions, + assignees, }: TaskContentProps) { const currentTab = useMemo(() => { if ( @@ -2909,7 +1380,9 @@ function TaskContent({ }, [currentTab, modulesData]); const isCustomModuleTab = !!customModule && !!customModule.config; - const hasCustomLayout = !!(form?.detailLayoutCode?.trim()); + // Представление области полей: пресет (@preset:) / JS-код / стандартный вид + const detailLayout = useMemo(() => parseDetailLayout(form?.detailLayoutCode), [form?.detailLayoutCode]); + const hasCustomLayout = detailLayout.kind !== 'standard'; const customLayoutExtraCtx = useMemo(() => { if (!hasCustomLayout || !form) return undefined; return { @@ -2918,8 +1391,10 @@ function TaskContent({ users, tabs: formTabs, form: { id: form.id, name: form.name }, + transitions, + assignees, }; - }, [hasCustomLayout, form, fields, statuses, users, formTabs]); + }, [hasCustomLayout, form, fields, statuses, users, formTabs, transitions, assignees]); const shouldShowSystemFields = useMemo(() => { if (!hasTabs) return true; @@ -2938,6 +1413,35 @@ function TaskContent({ ? undefined : parseInt(selectedTabId); + // Пропсы пресета представления (per-field редактирование через FieldValueRow) + const presetProps = useMemo(() => { + if (detailLayout.kind !== 'preset' || !form) return null; + return { + task, + formId, + form: { id: form.id, name: form.name }, + fields, + fieldValues, + fieldValuesMap, + tabs: formTabs, + statuses, + users, + roles, + transitions, + assignees, + tabId: taskFieldsTabId, + mode: 'desktop', + readOnly: isTaskFinal, + conditionContextStatuses, + isFieldVisibleForTask, + fieldErrors, + setFieldErrors, + }; + }, [detailLayout, form, task, formId, fields, fieldValues, fieldValuesMap, formTabs, statuses, users, roles, transitions, assignees, taskFieldsTabId, isTaskFinal, conditionContextStatuses, isFieldVisibleForTask, fieldErrors, setFieldErrors]); + + // Компонент выбранного пресета (модульный уровень — без remount-инварианта) + const PresetComponent = detailLayout.kind === 'preset' ? detailLayout.preset.component : undefined; + return ( <> {selectedTabId === 'subtasks' ? ( @@ -2967,12 +1471,18 @@ function TaskContent({ readOnly={isTaskFinal} /> ) : hasCustomLayout ? ( - } - fieldValues={fieldValuesMap} - extraCtx={customLayoutExtraCtx} - /> + PresetComponent && presetProps ? ( + + + + ) : ( + } + fieldValues={fieldValuesMap} + extraCtx={customLayoutExtraCtx} + /> + ) ) : ( <> {shouldShowSystemFields && ( @@ -3360,6 +1870,20 @@ const TaskDetail = () => { staleTime: 5 * 60 * 1000, }); + // Разбор представления карточки (пресет @preset: / JS-код / стандарт) — до early return, + // чтобы хук исполнителей ниже всегда вызывался в одном порядке + const detailLayoutInfo = parseDetailLayout(effectiveDetailData?.form?.detailLayoutCode); + const hasCustomLayoutEarly = detailLayoutInfo.kind !== 'standard'; + + // Исполнители задачи для слотов кастомного layout/пресета + // (тот же queryKey, что в InlineSystemFields — общий кэш, лишний запрос только при кастомном виде) + const { data: layoutAssigneesData } = useQuery<{ success: boolean; assignees: TaskAssigneeEntry[] }>({ + queryKey: ['/api/tasks', taskIdNumber, 'assignees'], + enabled: !!taskIdNumber && hasCustomLayoutEarly, + staleTime: 30 * 1000, + }); + const layoutAssignees = layoutAssigneesData?.assignees || []; + // Запрос напоминаний задачи const { data: remindersData } = useQuery<{ success: boolean; reminders: Array<{ id: number; remindAt: string; note?: string | null; recipients: ReminderRecipient[] }> }>({ queryKey: ['/api/tasks', taskIdNumber, 'reminders'], @@ -3880,14 +2404,17 @@ const TaskDetail = () => { rawFieldValuesMap, ); - // Кастомный JS-вид задачи - const hasCustomLayout = !!(form.detailLayoutCode?.trim()); + // Кастомный вид задачи: пресет (@preset:) или JS-код + const hasCustomLayout = detailLayoutInfo.kind !== 'standard'; + const allTransitionsForLayout: FormTransition[] = effectiveDetailData?.transitions || []; const customLayoutExtraCtx = hasCustomLayout ? { fields, statuses: (effectiveDetailData?.statuses || []) as FormStatus[], users, tabs: formTabs, form: { id: form.id, name: form.name }, + transitions: allTransitionsForLayout, + assignees: layoutAssignees, } : undefined; const taskFieldsTabId = !hasTabs || selectedTabId === 'chat' || selectedTabId === 'subtasks' || selectedTabId === 'related' || selectedTabId === 'history' || selectedTabId === 'access' @@ -3977,6 +2504,8 @@ const TaskDetail = () => { getUserName={getUserName} systemFieldsTabId={systemFieldsTabId} hasTabs={hasTabs} + transitions={allTransitionsForLayout} + assignees={layoutAssignees} /> ); @@ -3989,13 +2518,43 @@ const TaskDetail = () => { return { isTable, isCustomModule, customModule }; }; + // Пресет представления на мобильном: заменяет весь accordion (как JS-layout) + const MobilePresetComponent = detailLayoutInfo.kind === 'preset' ? detailLayoutInfo.preset.component : undefined; + const mobilePresetProps: LayoutPresetProps | null = detailLayoutInfo.kind === 'preset' ? { + task, + formId, + form: { id: form.id, name: form.name }, + fields, + fieldValues, + fieldValuesMap, + tabs: formTabs, + statuses: formStatuses, + users, + roles: reminderOrgRolesData?.roles || [], + transitions: allTransitionsForLayout, + assignees: layoutAssignees, + tabId: undefined, + mode: 'mobile', + readOnly: isTaskFinal, + conditionContextStatuses, + isFieldVisibleForTask, + fieldErrors, + setFieldErrors, + } : null; + const mobileTaskAccordion = hasCustomLayout ? ( - } - fieldValues={fieldValuesMap} - extraCtx={customLayoutExtraCtx} - /> + MobilePresetComponent && mobilePresetProps ? ( + + + + ) : ( + } + fieldValues={fieldValuesMap} + extraCtx={customLayoutExtraCtx} + /> + ) ) : ( {/* Fallback: форма без вкладок — системные поля и все поля одной секцией «Задача» */} diff --git a/client/src/themes/ThemeProvider.tsx b/client/src/themes/ThemeProvider.tsx index bf168f2..948197d 100644 --- a/client/src/themes/ThemeProvider.tsx +++ b/client/src/themes/ThemeProvider.tsx @@ -5,9 +5,9 @@ import { createContext, useContext, useEffect, useLayoutEffect, useState, useCallback, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import type { Skin } from './tokens'; +import type { Skin, DensityPreset } from './tokens'; import { BUILT_IN_SKINS, DEFAULT_SKIN_ID, getBuiltInSkinById, isBuiltInSkin } from './index'; -import { flattenTokens } from './tokens'; +import { generateSkinCss } from './tokens'; import { useAuth } from '@/hooks/useAuth'; import { apiRequest } from '@/lib/queryClient'; @@ -16,6 +16,32 @@ const SKIN_STORAGE_KEY = 'skin'; export type Theme = 'light' | 'dark'; +/** Тема 2.0: структурные варианты активного скина, разрешённые с дефолтами. */ +export interface ResolvedSkinComponents { + control: { variant: 'outline' | 'filled' | 'underline'; radius: 'sm' | 'md' | 'none' }; + label: { position: 'top' | 'inline'; casing: 'none' | 'uppercase'; weight: 'regular' | 'medium' }; + form: { columns: 1 | 2 }; + dialog: { mobile: 'sheet' | 'modal'; backdrop: 'dim' | 'blur' }; + table: { dividers: 'rows' | 'grid' | 'none'; header: 'muted' | 'plain' }; + button: { radius: 'sm' | 'md' | 'pill' }; + motion: { enabled: boolean }; + density: { preset: DensityPreset }; +} + +/** Дефолты соответствуют текущему виду интерфейса (до Темы 2.0). */ +// label.position: 'inline' — текущий вид карточки задачи (label и значение в одной строке); +// 'top' задаётся скином явно (например, скин pyrus) или пресетом представления. +export const DEFAULT_SKIN_COMPONENTS: ResolvedSkinComponents = { + control: { variant: 'outline', radius: 'md' }, + label: { position: 'inline', casing: 'none', weight: 'medium' }, + form: { columns: 2 }, + dialog: { mobile: 'modal', backdrop: 'dim' }, + table: { dividers: 'rows', header: 'muted' }, + button: { radius: 'md' }, + motion: { enabled: true }, + density: { preset: 'comfortable' }, +}; + interface ThemeContextType { theme: Theme; setTheme: (theme: Theme) => void; @@ -23,6 +49,7 @@ interface ThemeContextType { skinId: string; setSkinId: (skinId: string) => void; availableSkins: Skin[]; + skinComponents: ResolvedSkinComponents; } const ThemeContext = createContext(undefined); @@ -46,18 +73,14 @@ function getStoredSkinId(): string | null { /** Вставляет или обновляет runtime CSS для пользовательского скина. */ function applyRuntimeSkinCss(skinId: string, theme: Theme, skinTokens?: Skin['tokens']) { - if (!skinTokens) { + if (!skinTokens || !skinTokens[theme]) { const style = document.getElementById('skin-runtime'); if (style) style.textContent = ''; return; } - const vars = flattenTokens(skinTokens[theme]); - vars['--radius'] = skinTokens[theme].radius.lg; - const lines = Object.entries(vars) - .map(([name, value]) => ` ${name}: ${value};`) - .join('\n'); - const css = `[data-skin="${skinId}"][data-theme="${theme}"] {\n${lines}\n}`; + // Единый генератор: flatten токенов + переменные Темы 2.0 + motion-правило + const css = generateSkinCss({ id: skinId, name: skinId, tokens: skinTokens }, theme); let style = document.getElementById('skin-runtime'); if (!style) { @@ -190,6 +213,28 @@ export function ThemeProvider({ children }: ThemeProviderProps) { return [...BUILT_IN_SKINS, ...custom]; }, [customSkinsMap]); + // Тема 2.0: структурные варианты активного скина (components + density.preset) с дефолтами. + const skinComponents = useMemo(() => { + const skin = isBuiltInSkin(skinId) ? getBuiltInSkinById(skinId) : customSkinsMap[skinId]; + const themeTokens = skin?.tokens?.[theme] ?? skin?.tokens?.light; + const c = themeTokens?.components; + if (!c && !themeTokens?.density) return DEFAULT_SKIN_COMPONENTS; + return { + control: { variant: c?.control?.variant ?? 'outline', radius: c?.control?.radius ?? 'md' }, + label: { + position: c?.label?.position ?? 'inline', + casing: c?.label?.casing ?? 'none', + weight: c?.label?.weight ?? 'medium', + }, + form: { columns: c?.form?.columns ?? 2 }, + dialog: { mobile: c?.dialog?.mobile ?? 'modal', backdrop: c?.dialog?.backdrop ?? 'dim' }, + table: { dividers: c?.table?.dividers ?? 'rows', header: c?.table?.header ?? 'muted' }, + button: { radius: c?.button?.radius ?? 'md' }, + motion: { enabled: c?.motion?.enabled ?? true }, + density: { preset: themeTokens?.density?.preset ?? 'comfortable' }, + }; + }, [skinId, theme, customSkinsMap]); + const value: ThemeContextType = { theme, setTheme, @@ -197,6 +242,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { skinId, setSkinId, availableSkins, + skinComponents, }; return {children}; @@ -216,3 +262,8 @@ export function useSkin() { const skin = availableSkins.find((s) => s.id === skinId) ?? getBuiltInSkinById(DEFAULT_SKIN_ID)!; return { skinId, skin, setSkinId, availableSkins }; } + +/** Тема 2.0: структурные варианты компонентов активного скина (с дефолтами). */ +export function useSkinComponents(): ResolvedSkinComponents { + return useTheme().skinComponents; +} diff --git a/client/src/themes/index.ts b/client/src/themes/index.ts index 11ea665..85706e4 100644 --- a/client/src/themes/index.ts +++ b/client/src/themes/index.ts @@ -1,13 +1,14 @@ import type { Skin } from './tokens'; import { iistwinSkin } from './skins/iistwin'; import { omegapromSkin } from './skins/omegaprom'; +import { pyrusSkin } from './skins/pyrus'; import { twentySkin } from './skins/twenty'; export * from './tokens'; -export { iistwinSkin, omegapromSkin, twentySkin }; +export { iistwinSkin, omegapromSkin, pyrusSkin, twentySkin }; /** Встроенные скины, доступные из коробки. */ -export const BUILT_IN_SKINS: Skin[] = [iistwinSkin, twentySkin, omegapromSkin]; +export const BUILT_IN_SKINS: Skin[] = [iistwinSkin, twentySkin, omegapromSkin, pyrusSkin]; /** Скин по умолчанию. */ export const DEFAULT_SKIN_ID = 'iistwin'; diff --git a/client/src/themes/skins/pyrus.ts b/client/src/themes/skins/pyrus.ts new file mode 100644 index 0000000..fdbe4c0 --- /dev/null +++ b/client/src/themes/skins/pyrus.ts @@ -0,0 +1,308 @@ +import type { Skin } from '../tokens'; + +/** + * Скин в стиле Pyrus (тёмная тема как основная). + * Глубокий тёмный фон, панели чуть светлее, едва заметные бордеры, + * бирюзово-синие акценты, красно-розовый для просрочки/деструктива, + * небольшие скругления, компактная плотность. + * Светлая тема — нейтральная производная с тем же primary. + */ +export const pyrusSkin: Skin = { + id: 'pyrus', + name: 'Pyrus (тёмная)', + description: 'Dark-first скин по мотивам Pyrus: тёмный фон, бирюзовые акценты, компактная плотность.', + author: 'iistwin', + isEditable: false, + tokens: { + light: { + colors: { + background: '#f4f5f7', + foreground: '#1f2329', + card: '#ffffff', + cardForeground: '#1f2329', + popover: '#ffffff', + popoverForeground: '#1f2329', + primary: '#3d9bd4', + primaryForeground: '#0a141c', + secondary: '#e8eaee', + secondaryForeground: '#1f2329', + muted: '#eceef1', + mutedForeground: '#5f6670', + accent: '#d9edf8', + accentForeground: '#14567e', + destructive: '#d92b43', + destructiveForeground: '#ffffff', + border: '#e2e4e8', + input: '#ffffff', + ring: '#3d9bd4', + + backgroundSecondary: '#fafbfc', + backgroundTertiary: '#eef0f3', + backgroundQuaternary: '#e4e6ea', + backgroundTransparentPrimary: 'rgba(255, 255, 255, 0.5)', + backgroundTransparentSecondary: 'rgba(255, 255, 255, 0.4)', + backgroundTransparentTertiary: 'rgba(15, 23, 42, 0.12)', + backgroundTransparentMedium: 'rgba(15, 23, 42, 0.06)', + backgroundTransparentLight: 'rgba(15, 23, 42, 0.04)', + backgroundTransparentLighter: 'rgba(15, 23, 42, 0.02)', + backgroundTransparentStrong: 'rgba(15, 23, 42, 0.16)', + backgroundTransparentDanger: 'rgba(217, 43, 67, 0.10)', + backgroundTransparentBlue: 'rgba(61, 155, 212, 0.12)', + backgroundTransparentOrange: 'rgba(255, 156, 0, 0.16)', + backgroundTransparentSuccess: 'rgba(23, 166, 93, 0.12)', + backgroundNoisy: '#ffffff', + + backgroundOverlayPrimary: 'rgba(0, 0, 0, 0.72)', + backgroundOverlaySecondary: 'rgba(0, 0, 0, 0.36)', + backgroundOverlayTertiary: 'rgba(0, 0, 0, 0.07)', + + blurLight: 'blur(6px) saturate(200%) contrast(50%) brightness(130%)', + blurMedium: 'blur(12px) saturate(200%) contrast(50%) brightness(130%)', + blurStrong: 'blur(20px) saturate(200%) contrast(50%) brightness(130%)', + + boxShadowColor: 'rgba(15, 23, 42, 0.06)', + boxShadowUnderline: '0px 1px 0px 0px rgba(15, 23, 42, 0.12)', + boxShadowSuperHeavy: + '0px 0px 8px 0px rgba(15, 23, 42, 0.12), 0px 8px 64px -16px rgba(15, 23, 42, 0.40), 0px 24px 56px -16px rgba(15, 23, 42, 0.08)', + + fontPrimary: '#1f2329', + fontSecondary: '#5f6670', + fontTertiary: '#8a9098', + fontLight: '#a8aeb6', + fontExtraLight: '#c9cdd3', + fontInverted: '#ffffff', + + borderLight: '#eef0f3', + borderMedium: '#e2e4e8', + borderStrong: '#c9cdd3', + + statusDone: '#15935b', + statusProgress: '#2b7fb3', + statusOverdue: '#d92b43', + statusPaused: '#8a9098', + + warning: '#fef3c7', + warningForeground: '#92400e', + success: '#dcfce7', + successForeground: '#166534', + info: '#e0f0fa', + infoForeground: '#1a618f', + + chart1: '#1f2329', + chart2: '#15935b', + chart3: '#f0a42a', + chart4: '#3d9bd4', + chart5: '#d92b43', + + sidebar: '#f0f1f4', + sidebarForeground: '#1f2329', + sidebarPrimary: '#3d9bd4', + sidebarPrimaryForeground: '#0a141c', + sidebarAccent: '#e4e6ea', + sidebarAccentForeground: '#1f2329', + sidebarBorder: '#e2e4e8', + sidebarRing: '#3d9bd4', + }, + radius: { + xs: '2px', + sm: '4px', + md: '6px', + lg: '8px', + xl: '12px', + xxl: '20px', + pill: '999px', + rounded: '100%', + smRound: '4px', + mdRound: '6px', + }, + shadows: { + sm: '0px 2px 4px 0px rgba(15, 23, 42, 0.04), 0px 0px 4px 0px rgba(15, 23, 42, 0.06)', + md: '2px 4px 16px 0px rgba(15, 23, 42, 0.12), 0px 2px 4px 0px rgba(15, 23, 42, 0.06)', + lg: '2px 4px 16px 0px rgba(15, 23, 42, 0.10), 0px 2px 4px 0px rgba(15, 23, 42, 0.04)', + xl: '0px 0px 8px 0px rgba(15, 23, 42, 0.06), 0px 8px 32px -8px rgba(15, 23, 42, 0.10)', + underline: '0px 1px 0px 0px rgba(15, 23, 42, 0.12)', + superHeavy: + '0px 0px 8px 0px rgba(15, 23, 42, 0.12), 0px 8px 64px -16px rgba(15, 23, 42, 0.40), 0px 24px 56px -16px rgba(15, 23, 42, 0.08)', + }, + font: { + sans: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + mono: '"DM Mono", "SF Mono", "Monaco", monospace', + size: { + xs: '0.625rem', + sm: '0.85rem', + md: '0.92rem', + lg: '1rem', + xl: '1.23rem', + }, + weight: { + regular: 400, + medium: 500, + semibold: 600, + }, + lineHeight: { + md: '1.1', + lg: '1.5', + }, + }, + spacing: { + unit: '4px', + }, + density: { + preset: 'compact', + }, + components: { + control: { variant: 'filled' }, + label: { position: 'top', casing: 'uppercase', weight: 'regular' }, + form: { columns: 1 }, + dialog: { mobile: 'sheet', backdrop: 'dim' }, + table: { dividers: 'rows', header: 'muted' }, + button: { radius: 'sm' }, + }, + }, + dark: { + colors: { + background: '#1b1e24', + foreground: '#e6e8eb', + card: '#23272f', + cardForeground: '#e6e8eb', + popover: '#23272f', + popoverForeground: '#e6e8eb', + primary: '#3d9bd4', + primaryForeground: '#0a141c', + secondary: '#2a2e37', + secondaryForeground: '#e6e8eb', + muted: '#2a2e37', + mutedForeground: '#9aa0a8', + accent: '#1d3a4d', + accentForeground: '#8ec8ea', + destructive: '#f4485d', + destructiveForeground: '#21060c', + border: 'rgba(255, 255, 255, 0.08)', + input: '#2a2e37', + ring: '#3d9bd4', + + backgroundSecondary: '#1f2229', + backgroundTertiary: '#23272f', + backgroundQuaternary: '#2a2e37', + backgroundTransparentPrimary: 'rgba(0, 0, 0, 0.5)', + backgroundTransparentSecondary: 'rgba(0, 0, 0, 0.4)', + backgroundTransparentTertiary: 'rgba(255, 255, 255, 0.12)', + backgroundTransparentMedium: 'rgba(255, 255, 255, 0.06)', + backgroundTransparentLight: 'rgba(255, 255, 255, 0.04)', + backgroundTransparentLighter: 'rgba(255, 255, 255, 0.02)', + backgroundTransparentStrong: 'rgba(255, 255, 255, 0.14)', + backgroundTransparentDanger: 'rgba(244, 72, 93, 0.16)', + backgroundTransparentBlue: 'rgba(61, 155, 212, 0.18)', + backgroundTransparentOrange: 'rgba(255, 156, 0, 0.16)', + backgroundTransparentSuccess: 'rgba(52, 199, 123, 0.16)', + backgroundNoisy: '#1b1e24', + + backgroundOverlayPrimary: 'rgba(0, 0, 0, 0.72)', + backgroundOverlaySecondary: 'rgba(0, 0, 0, 0.36)', + backgroundOverlayTertiary: 'rgba(255, 255, 255, 0.07)', + + blurLight: 'blur(6px) saturate(200%) contrast(100%) brightness(130%)', + blurMedium: 'blur(12px) saturate(200%) contrast(100%) brightness(130%)', + blurStrong: 'blur(20px) saturate(200%) contrast(100%) brightness(130%)', + + boxShadowColor: 'rgba(0, 0, 0, 0.5)', + boxShadowUnderline: '0px 1px 0px 0px rgba(0, 0, 0, 0.32)', + boxShadowSuperHeavy: + '2px 4px 16px 0px rgba(0, 0, 0, 0.12), 0px 2px 4px 0px rgba(0, 0, 0, 0.04)', + + fontPrimary: '#e6e8eb', + fontSecondary: '#9aa0a8', + fontTertiary: '#6f7680', + fontLight: '#555c66', + fontExtraLight: '#3f454e', + fontInverted: '#10151b', + + borderLight: 'rgba(255, 255, 255, 0.06)', + borderMedium: 'rgba(255, 255, 255, 0.08)', + borderStrong: 'rgba(255, 255, 255, 0.16)', + + statusDone: '#34c77b', + statusProgress: '#3d9bd4', + statusOverdue: '#f4485d', + statusPaused: '#6f7680', + + warning: '#3a2a10', + warningForeground: '#fbbf24', + success: '#0e2b1c', + successForeground: '#34c77b', + info: '#12293a', + infoForeground: '#6fc0ee', + + chart1: '#e6e8eb', + chart2: '#34c77b', + chart3: '#f0a42a', + chart4: '#3d9bd4', + chart5: '#f4485d', + + sidebar: '#171a1f', + sidebarForeground: '#e6e8eb', + sidebarPrimary: '#3d9bd4', + sidebarPrimaryForeground: '#0a141c', + sidebarAccent: '#23272f', + sidebarAccentForeground: '#e6e8eb', + sidebarBorder: 'rgba(255, 255, 255, 0.08)', + sidebarRing: '#3d9bd4', + }, + radius: { + xs: '2px', + sm: '4px', + md: '6px', + lg: '8px', + xl: '12px', + xxl: '20px', + pill: '999px', + rounded: '100%', + smRound: '4px', + mdRound: '6px', + }, + shadows: { + sm: '0px 2px 4px 0px rgba(0, 0, 0, 0.2), 0px 0px 4px 0px rgba(0, 0, 0, 0.3)', + md: '2px 4px 16px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(0, 0, 0, 0.08)', + lg: '2px 4px 16px 0px rgba(0, 0, 0, 0.35), 0px 2px 4px 0px rgba(0, 0, 0, 0.2)', + xl: '0px 0px 8px 0px rgba(0, 0, 0, 0.3), 0px 8px 32px -8px rgba(0, 0, 0, 0.4)', + underline: '0px 1px 0px 0px rgba(0, 0, 0, 0.32)', + superHeavy: + '2px 4px 16px 0px rgba(0, 0, 0, 0.12), 0px 2px 4px 0px rgba(0, 0, 0, 0.04)', + }, + font: { + sans: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + mono: '"DM Mono", "SF Mono", "Monaco", monospace', + size: { + xs: '0.625rem', + sm: '0.85rem', + md: '0.92rem', + lg: '1rem', + xl: '1.23rem', + }, + weight: { + regular: 400, + medium: 500, + semibold: 600, + }, + lineHeight: { + md: '1.1', + lg: '1.5', + }, + }, + spacing: { + unit: '4px', + }, + density: { + preset: 'compact', + }, + components: { + control: { variant: 'filled' }, + label: { position: 'top', casing: 'uppercase', weight: 'regular' }, + form: { columns: 1 }, + dialog: { mobile: 'sheet', backdrop: 'dim' }, + table: { dividers: 'rows', header: 'muted' }, + button: { radius: 'sm' }, + }, + }, + }, +}; diff --git a/client/src/themes/styles/generated-skins.css b/client/src/themes/styles/generated-skins.css index 3667bfb..47ad8fa 100644 --- a/client/src/themes/styles/generated-skins.css +++ b/client/src/themes/styles/generated-skins.css @@ -634,4 +634,236 @@ --tw-line-height-lg: 1.5; --tw-spacing-unit: 0.25rem; --radius: 0.75rem; +} + +[data-skin="pyrus"][data-theme="light"] { + --background: #f4f5f7; + --foreground: #1f2329; + --card: #ffffff; + --card-foreground: #1f2329; + --popover: #ffffff; + --popover-foreground: #1f2329; + --primary: #3d9bd4; + --primary-foreground: #0a141c; + --secondary: #e8eaee; + --secondary-foreground: #1f2329; + --muted: #eceef1; + --muted-foreground: #5f6670; + --accent: #d9edf8; + --accent-foreground: #14567e; + --destructive: #d92b43; + --destructive-foreground: #ffffff; + --border: #e2e4e8; + --input: #ffffff; + --ring: #3d9bd4; + --tw-background-secondary: #fafbfc; + --tw-background-tertiary: #eef0f3; + --tw-background-quaternary: #e4e6ea; + --tw-background-transparent-primary: rgba(255, 255, 255, 0.5); + --tw-background-transparent-secondary: rgba(255, 255, 255, 0.4); + --tw-background-transparent-tertiary: rgba(15, 23, 42, 0.12); + --tw-background-transparent-medium: rgba(15, 23, 42, 0.06); + --tw-background-transparent-light: rgba(15, 23, 42, 0.04); + --tw-background-transparent-lighter: rgba(15, 23, 42, 0.02); + --tw-background-transparent-strong: rgba(15, 23, 42, 0.16); + --tw-background-transparent-danger: rgba(217, 43, 67, 0.10); + --tw-background-transparent-blue: rgba(61, 155, 212, 0.12); + --tw-background-transparent-orange: rgba(255, 156, 0, 0.16); + --tw-background-transparent-success: rgba(23, 166, 93, 0.12); + --tw-background-noisy: #ffffff; + --tw-background-overlay-primary: rgba(0, 0, 0, 0.72); + --tw-background-overlay-secondary: rgba(0, 0, 0, 0.36); + --tw-background-overlay-tertiary: rgba(0, 0, 0, 0.07); + --tw-blur-light: blur(6px) saturate(200%) contrast(50%) brightness(130%); + --tw-blur-medium: blur(12px) saturate(200%) contrast(50%) brightness(130%); + --tw-blur-strong: blur(20px) saturate(200%) contrast(50%) brightness(130%); + --tw-box-shadow-color: rgba(15, 23, 42, 0.06); + --tw-box-shadow-underline: 0px 1px 0px 0px rgba(15, 23, 42, 0.12); + --tw-box-shadow-super-heavy: 0px 0px 8px 0px rgba(15, 23, 42, 0.12), 0px 8px 64px -16px rgba(15, 23, 42, 0.40), 0px 24px 56px -16px rgba(15, 23, 42, 0.08); + --tw-font-primary: #1f2329; + --tw-font-secondary: #5f6670; + --tw-font-tertiary: #8a9098; + --tw-font-light: #a8aeb6; + --tw-font-extra-light: #c9cdd3; + --tw-font-inverted: #ffffff; + --tw-border-light: #eef0f3; + --tw-border-medium: #e2e4e8; + --tw-border-strong: #c9cdd3; + --status-done: #15935b; + --status-progress: #2b7fb3; + --status-overdue: #d92b43; + --status-paused: #8a9098; + --warning: #fef3c7; + --warning-foreground: #92400e; + --success: #dcfce7; + --success-foreground: #166534; + --info: #e0f0fa; + --info-foreground: #1a618f; + --chart1: #1f2329; + --chart2: #15935b; + --chart3: #f0a42a; + --chart4: #3d9bd4; + --chart5: #d92b43; + --sidebar: #f0f1f4; + --sidebar-foreground: #1f2329; + --sidebar-primary: #3d9bd4; + --sidebar-primary-foreground: #0a141c; + --sidebar-accent: #e4e6ea; + --sidebar-accent-foreground: #1f2329; + --sidebar-border: #e2e4e8; + --sidebar-ring: #3d9bd4; + --tw-border-radius-xs: 2px; + --tw-border-radius-sm: 4px; + --tw-border-radius-md: 6px; + --tw-border-radius-lg: 8px; + --tw-border-radius-xl: 12px; + --tw-border-radius-xxl: 20px; + --tw-border-radius-pill: 999px; + --tw-border-radius-rounded: 100%; + --tw-border-radius-sm-round: 4px; + --tw-border-radius-md-round: 6px; + --tw-box-shadow-sm: 0px 2px 4px 0px rgba(15, 23, 42, 0.04), 0px 0px 4px 0px rgba(15, 23, 42, 0.06); + --tw-box-shadow-md: 2px 4px 16px 0px rgba(15, 23, 42, 0.12), 0px 2px 4px 0px rgba(15, 23, 42, 0.06); + --tw-box-shadow-lg: 2px 4px 16px 0px rgba(15, 23, 42, 0.10), 0px 2px 4px 0px rgba(15, 23, 42, 0.04); + --tw-box-shadow-xl: 0px 0px 8px 0px rgba(15, 23, 42, 0.06), 0px 8px 32px -8px rgba(15, 23, 42, 0.10); + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "DM Mono", "SF Mono", "Monaco", monospace; + --tw-font-size-xs: 0.625rem; + --tw-font-size-sm: 0.85rem; + --tw-font-size-md: 0.92rem; + --tw-font-size-lg: 1rem; + --tw-font-size-xl: 1.23rem; + --tw-font-weight-regular: 400; + --tw-font-weight-medium: 500; + --tw-font-weight-semibold: 600; + --tw-line-height-md: 1.1; + --tw-line-height-lg: 1.5; + --tw-spacing-unit: 4px; + --radius: 8px; + --control-height-form: 28px; + --control-height-detail: 24px; + --control-height-cell: 24px; + --table-row-height: 34px; + --field-row-min: 24px; + --control-bg: var(--muted); + --control-border-color: transparent; + --label-casing: uppercase; + --label-weight: 400; + --button-radius: calc(var(--radius) - 4px); +} + +[data-skin="pyrus"][data-theme="dark"] { + --background: #1b1e24; + --foreground: #e6e8eb; + --card: #23272f; + --card-foreground: #e6e8eb; + --popover: #23272f; + --popover-foreground: #e6e8eb; + --primary: #3d9bd4; + --primary-foreground: #0a141c; + --secondary: #2a2e37; + --secondary-foreground: #e6e8eb; + --muted: #2a2e37; + --muted-foreground: #9aa0a8; + --accent: #1d3a4d; + --accent-foreground: #8ec8ea; + --destructive: #f4485d; + --destructive-foreground: #21060c; + --border: rgba(255, 255, 255, 0.08); + --input: #2a2e37; + --ring: #3d9bd4; + --tw-background-secondary: #1f2229; + --tw-background-tertiary: #23272f; + --tw-background-quaternary: #2a2e37; + --tw-background-transparent-primary: rgba(0, 0, 0, 0.5); + --tw-background-transparent-secondary: rgba(0, 0, 0, 0.4); + --tw-background-transparent-tertiary: rgba(255, 255, 255, 0.12); + --tw-background-transparent-medium: rgba(255, 255, 255, 0.06); + --tw-background-transparent-light: rgba(255, 255, 255, 0.04); + --tw-background-transparent-lighter: rgba(255, 255, 255, 0.02); + --tw-background-transparent-strong: rgba(255, 255, 255, 0.14); + --tw-background-transparent-danger: rgba(244, 72, 93, 0.16); + --tw-background-transparent-blue: rgba(61, 155, 212, 0.18); + --tw-background-transparent-orange: rgba(255, 156, 0, 0.16); + --tw-background-transparent-success: rgba(52, 199, 123, 0.16); + --tw-background-noisy: #1b1e24; + --tw-background-overlay-primary: rgba(0, 0, 0, 0.72); + --tw-background-overlay-secondary: rgba(0, 0, 0, 0.36); + --tw-background-overlay-tertiary: rgba(255, 255, 255, 0.07); + --tw-blur-light: blur(6px) saturate(200%) contrast(100%) brightness(130%); + --tw-blur-medium: blur(12px) saturate(200%) contrast(100%) brightness(130%); + --tw-blur-strong: blur(20px) saturate(200%) contrast(100%) brightness(130%); + --tw-box-shadow-color: rgba(0, 0, 0, 0.5); + --tw-box-shadow-underline: 0px 1px 0px 0px rgba(0, 0, 0, 0.32); + --tw-box-shadow-super-heavy: 2px 4px 16px 0px rgba(0, 0, 0, 0.12), 0px 2px 4px 0px rgba(0, 0, 0, 0.04); + --tw-font-primary: #e6e8eb; + --tw-font-secondary: #9aa0a8; + --tw-font-tertiary: #6f7680; + --tw-font-light: #555c66; + --tw-font-extra-light: #3f454e; + --tw-font-inverted: #10151b; + --tw-border-light: rgba(255, 255, 255, 0.06); + --tw-border-medium: rgba(255, 255, 255, 0.08); + --tw-border-strong: rgba(255, 255, 255, 0.16); + --status-done: #34c77b; + --status-progress: #3d9bd4; + --status-overdue: #f4485d; + --status-paused: #6f7680; + --warning: #3a2a10; + --warning-foreground: #fbbf24; + --success: #0e2b1c; + --success-foreground: #34c77b; + --info: #12293a; + --info-foreground: #6fc0ee; + --chart1: #e6e8eb; + --chart2: #34c77b; + --chart3: #f0a42a; + --chart4: #3d9bd4; + --chart5: #f4485d; + --sidebar: #171a1f; + --sidebar-foreground: #e6e8eb; + --sidebar-primary: #3d9bd4; + --sidebar-primary-foreground: #0a141c; + --sidebar-accent: #23272f; + --sidebar-accent-foreground: #e6e8eb; + --sidebar-border: rgba(255, 255, 255, 0.08); + --sidebar-ring: #3d9bd4; + --tw-border-radius-xs: 2px; + --tw-border-radius-sm: 4px; + --tw-border-radius-md: 6px; + --tw-border-radius-lg: 8px; + --tw-border-radius-xl: 12px; + --tw-border-radius-xxl: 20px; + --tw-border-radius-pill: 999px; + --tw-border-radius-rounded: 100%; + --tw-border-radius-sm-round: 4px; + --tw-border-radius-md-round: 6px; + --tw-box-shadow-sm: 0px 2px 4px 0px rgba(0, 0, 0, 0.2), 0px 0px 4px 0px rgba(0, 0, 0, 0.3); + --tw-box-shadow-md: 2px 4px 16px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(0, 0, 0, 0.08); + --tw-box-shadow-lg: 2px 4px 16px 0px rgba(0, 0, 0, 0.35), 0px 2px 4px 0px rgba(0, 0, 0, 0.2); + --tw-box-shadow-xl: 0px 0px 8px 0px rgba(0, 0, 0, 0.3), 0px 8px 32px -8px rgba(0, 0, 0, 0.4); + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "DM Mono", "SF Mono", "Monaco", monospace; + --tw-font-size-xs: 0.625rem; + --tw-font-size-sm: 0.85rem; + --tw-font-size-md: 0.92rem; + --tw-font-size-lg: 1rem; + --tw-font-size-xl: 1.23rem; + --tw-font-weight-regular: 400; + --tw-font-weight-medium: 500; + --tw-font-weight-semibold: 600; + --tw-line-height-md: 1.1; + --tw-line-height-lg: 1.5; + --tw-spacing-unit: 4px; + --radius: 8px; + --control-height-form: 28px; + --control-height-detail: 24px; + --control-height-cell: 24px; + --table-row-height: 34px; + --field-row-min: 24px; + --control-bg: var(--muted); + --control-border-color: transparent; + --label-casing: uppercase; + --label-weight: 400; + --button-radius: calc(var(--radius) - 4px); } \ No newline at end of file diff --git a/client/src/themes/tokens.ts b/client/src/themes/tokens.ts index 37bf7e0..850924e 100644 --- a/client/src/themes/tokens.ts +++ b/client/src/themes/tokens.ts @@ -22,6 +22,34 @@ export interface SkinThemeTokens { shadows: ShadowTokens; font: FontTokens; spacing: SpacingTokens; + // Тема 2.0: плотность и структурные варианты компонентов. + // Опциональны: скины без этих полей выглядят как раньше (fallback'и в var()). + density?: DensityTokens; + components?: ComponentTokens; +} + +/** Пресеты плотности страницы. */ +export type DensityPreset = 'compact' | 'comfortable' | 'spacious'; + +/** Тема 2.0: переопределения плотности (высоты контролов/строк). */ +export interface DensityTokens { + preset?: DensityPreset; + controlForm?: string; // высота контрола в форме создания + controlDetail?: string; // высота контрола в карточке задачи + controlCell?: string; // высота контрола в ячейке таблицы + tableRow?: string; // высота строки таблицы + fieldRow?: string; // min-height строки поля в карточке (desktop) +} + +/** Тема 2.0: структурные варианты компонентов (не CSS-значения, маппятся в переменные). */ +export interface ComponentTokens { + control?: { variant?: 'outline' | 'filled' | 'underline'; radius?: 'sm' | 'md' | 'none' }; + label?: { position?: 'top' | 'inline'; casing?: 'none' | 'uppercase'; weight?: 'regular' | 'medium' }; + form?: { columns?: 1 | 2 }; + dialog?: { mobile?: 'sheet' | 'modal'; backdrop?: 'dim' | 'blur' }; + table?: { dividers?: 'rows' | 'grid' | 'none'; header?: 'muted' | 'plain' }; + button?: { radius?: 'sm' | 'md' | 'pill' }; + motion?: { enabled?: boolean }; } export interface ColorTokens { @@ -163,6 +191,12 @@ export interface FontTokens { export interface SpacingTokens { unit: string; + // Тема 2.0 (опционально): отступы полей и контента + fieldGapY?: string; // отступ между строками полей (дефолт '8px') + fieldGapX?: string; // горизонтальный отступ между полями (дефолт '16px') + controlPaddingX?: string; // внутренний паддинг контролов (дефолт '10px') + sectionGap?: string; // отступ между секциями (дефолт '16px') + pageGutter?: string; // боковые поля контента (дефолт '16px') } /** Список редактируемых цветовых токенов для UI-редактора. */ @@ -297,13 +331,25 @@ export function tokenPathToCssVar(path: string[]): string { } if (path[0] === 'spacing') { + // Тема 2.0: отступы полей маппятся на семантические переменные + const spacingV2: Record = { + fieldGapY: '--field-gap-y', + fieldGapX: '--field-gap-x', + controlPaddingX: '--control-px', + sectionGap: '--section-gap', + pageGutter: '--page-gutter', + }; + if (spacingV2[path[1]]) return spacingV2[path[1]]; return '--tw-spacing-' + camelToKebab(path[1]); } return '--' + path.map(camelToKebab).join('-'); } -/** Flatten объекта токенов в плоскую карту CSS-переменных. */ +/** Flatten объекта токенов в плоскую карту CSS-переменных. + * components/density — структурные варианты (не CSS), пропускаются: + * они раскрываются в переменные через componentsToCssVars(). + */ export function flattenTokens( tokens: object, prefix: string[] = [], @@ -311,28 +357,102 @@ export function flattenTokens( const result: Record = {}; for (const [key, value] of Object.entries(tokens)) { + // Структурные секции Темы 2.0 — не CSS-переменные + if (prefix.length === 0 && (key === 'components' || key === 'density')) continue; const path = [...prefix, key]; if (value && typeof value === 'object' && !Array.isArray(value)) { Object.assign(result, flattenTokens(value as Record, path)); } else { - result[tokenPathToCssVar(path)] = value as string | number; + // Не-строковые/не-числовые leaf (boolean и т.п.) в CSS не попадают + if (typeof value !== 'string' && typeof value !== 'number') continue; + result[tokenPathToCssVar(path)] = value; } } return result; } +/** Пресеты плотности: высоты контролов и строк для каждого варианта. */ +export const DENSITY_PRESETS: Record>> = { + compact: { controlForm: '28px', controlDetail: '24px', controlCell: '24px', tableRow: '34px', fieldRow: '24px' }, + comfortable: { controlForm: '32px', controlDetail: '28px', controlCell: '28px', tableRow: '40px', fieldRow: '28px' }, + spacious: { controlForm: '36px', controlDetail: '32px', controlCell: '32px', tableRow: '48px', fieldRow: '32px' }, +}; + +/** Маппинг структурных вариантов (components/density) Темы 2.0 в CSS-переменные. + * Вызывается только для скинов, у которых заданы components или density; + * для остальных возвращает {} — потребители используют fallback'и в var(). + */ +export function componentsToCssVars(themeTokens: SkinThemeTokens): Record { + const { components, density } = themeTokens; + if (!components && !density) return {}; + const vars: Record = {}; + + // Плотность: пресет раскрывается в набор значений, явные поля перекрывают пресет + const preset = DENSITY_PRESETS[density?.preset ?? 'comfortable']; + vars['--control-height-form'] = density?.controlForm ?? preset.controlForm; + vars['--control-height-detail'] = density?.controlDetail ?? preset.controlDetail; + vars['--control-height-cell'] = density?.controlCell ?? preset.controlCell; + vars['--table-row-height'] = density?.tableRow ?? preset.tableRow; + vars['--field-row-min'] = density?.fieldRow ?? preset.fieldRow; + + // Вариант контрола полей: outline (дефолт) / filled / underline + const variant = components?.control?.variant ?? 'outline'; + if (variant === 'filled') { + vars['--control-bg'] = 'var(--muted)'; + vars['--control-border-color'] = 'transparent'; + } else if (variant === 'underline') { + vars['--control-border-width'] = '0 0 1px 0'; + vars['--control-radius'] = '0'; + } + const controlRadius = components?.control?.radius; + if (controlRadius && variant !== 'underline') { + vars['--control-radius'] = + controlRadius === 'sm' ? 'calc(var(--radius) - 4px)' + : controlRadius === 'none' ? '0' + : 'calc(var(--radius) - 2px)'; + } + + // Подписи полей (position — этап 2, здесь не применяется) + if (components?.label?.casing === 'uppercase') vars['--label-casing'] = 'uppercase'; + if (components?.label?.weight) { + vars['--label-weight'] = components.label.weight === 'medium' ? '500' : '400'; + } + + // Таблицы: шапка без фона + if (components?.table?.header === 'plain') vars['--table-header-bg'] = 'transparent'; + + // Кнопки: радиус + const buttonRadius = components?.button?.radius; + if (buttonRadius) { + vars['--button-radius'] = + buttonRadius === 'pill' ? '9999px' + : buttonRadius === 'sm' ? 'calc(var(--radius) - 4px)' + : 'calc(var(--radius) - 2px)'; + } + + return vars; +} + /** Генерирует CSS-блок для скина и темы. */ export function generateSkinCss(skin: Skin, theme: 'light' | 'dark'): string { const themeTokens = skin.tokens[theme]; const vars = flattenTokens(themeTokens); // shadcn/ui использует --radius как базовый радиус lg. vars['--radius'] = themeTokens.radius.lg; + // Тема 2.0: структурные варианты раскрываются в CSS-переменные + Object.assign(vars, componentsToCssVars(themeTokens)); const lines = Object.entries(vars) .map(([name, value]) => ` ${name}: ${value};`) .join('\n'); - return `[data-skin="${skin.id}"][data-theme="${theme}"] {\n${lines}\n}`; + const selector = `[data-skin="${skin.id}"][data-theme="${theme}"]`; + let css = `${selector} {\n${lines}\n}`; + // motion.enabled === false: глушим анимации и переходы внутри скина + if (themeTokens.components?.motion?.enabled === false) { + css += `\n${selector} * {\n transition-duration: 0.01ms !important;\n animation-duration: 0.01ms !important;\n}`; + } + return css; } /** Генерирует CSS для всех скинов и тем. */