Files
iistwin/client/src/components/task-fields/FieldValueRow.tsx

1607 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, string>;
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 <span className="text-muted-foreground">Загрузка...</span>;
const liveTask = tasksData?.tasks?.find(t => t.id.toString() === taskId?.toString());
const task = liveTask ?? offlineTask;
if (!task) return <span className="text-muted-foreground">Задача не найдена</span>;
return (
<span className="inline-flex items-center gap-2 flex-wrap">
<Link href={`/forms/${linkedFormId}/tasks/${task.id}`}>
<span className="inline-flex items-center gap-1 text-primary hover:underline cursor-pointer">
<Link2 className="w-3 h-3" />
{task.title}
</span>
</Link>
{relationType === 'parent' && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-muted text-muted-foreground">
Родитель
</span>
)}
{relationType === 'child' && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-muted text-muted-foreground">
Дочерняя
</span>
)}
</span>
);
}
// Компонент для отображения значения из справочника
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<string | number> }) {
const { isOnline } = useOfflineSyncContext();
const [offlineData, setOfflineData] = useState<DirectoryData | null>(null);
const isArray = Array.isArray(rowId);
const rowIds = isArray ? rowId.map(String) : [String(rowId)];
const { data: tableData, isLoading } = useQuery<DirectoryData>({
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 <span className="text-muted-foreground">Загрузка...</span>;
const table = effectiveData?.table;
if (!table) return <span className="text-muted-foreground">Таблица не найдена</span>;
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 <span className="text-muted-foreground">Запись не найдена</span>;
return (
<span className="inline-flex items-center gap-1">
<Table className="w-3 h-3 text-muted-foreground" />
{labels.join(', ')}
</span>
);
}
// Компонент для рендеринга кнопки с действием
function ButtonField({
field,
taskId,
allFieldValues,
allFields,
}: {
field: FormFieldData;
taskId?: number;
allFieldValues?: Record<string, unknown>;
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<string, unknown> | 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<string, unknown> = {};
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 (
<div className="py-2">
<Button
variant="outline"
onClick={handleButtonClick}
disabled={!isConfigured}
data-testid={`button-field-${field.code}`}
>
{field.buttonActionType === 'open_url' && <ExternalLink className="w-4 h-4 mr-2" />}
{field.buttonActionType === 'create_task' && <PlusCircle className="w-4 h-4 mr-2" />}
{field.buttonActionType === 'generate_document' && <FileText className="w-4 h-4 mr-2" />}
{field.name}
</Button>
{field.buttonActionType === 'create_task' && field.buttonFormId && (
<CreateTaskDialog
formId={field.buttonFormId}
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
initialValues={buildInitialValues()}
parentTaskId={buttonOptions.relationType === 'child' ? taskId : undefined}
onCreated={handleCreated}
/>
)}
</div>
);
}
// Типы полей, поддерживающие 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<string, unknown>;
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<string, unknown>;
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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="group/field flex items-start gap-1 py-0.5 -mx-1 px-1 rounded transition-colors hover:bg-muted/20">
<div className="w-6 h-5 shrink-0 flex items-center justify-center">
<button
type="button"
onClick={(e) => { e.stopPropagation(); fileInputRef.current?.click(); }}
disabled={isUploading || (field.maxFileCount != null && files.length >= field.maxFileCount)}
className="inline-flex items-center justify-center w-5 h-5 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors disabled:opacity-30"
title="Добавить файл"
>
<Upload className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex-1 min-w-0 flex flex-wrap items-center gap-x-2 gap-y-0.5">
<strong>{field.name}:</strong>{' '}
{field.maxFileCount != null && (
<span className="text-xs text-muted-foreground">({files.length}/{field.maxFileCount})</span>
)}
{isUploading ? (
<span className="text-muted-foreground text-sm">Загрузка...</span>
) : files.length > 0 ? (
<>
<div className="flex flex-1 min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5">
{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 (
<span key={idx} className="inline-flex items-center gap-1 group/file bg-muted/40 hover:bg-muted/60 rounded px-1.5 py-0.5 min-w-0 max-w-full">
{canPreview ? (
<button
type="button"
onClick={() => setViewerFile({ url: fileInfo.url, name: fileInfo.name })}
className="inline-flex items-center gap-1 text-sm text-primary hover:underline min-w-0"
data-testid={`file-link-${field.code}-${idx}`}
>
<FileText className="w-3 h-3 shrink-0" />
<span className="truncate max-w-[200px]">{fileInfo.name}</span>
{fileInfo.size !== undefined && (
<span className="text-muted-foreground text-xs shrink-0">({formatFileSize(fileInfo.size)})</span>
)}
</button>
) : (
<a
href={addFileToken(fileInfo.url)}
onClick={(e) => { 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}`}
>
<FileText className="w-3 h-3 shrink-0" />
<span className="truncate max-w-[200px]">{fileInfo.name}</span>
{fileInfo.size !== undefined && (
<span className="text-muted-foreground text-xs shrink-0">({formatFileSize(fileInfo.size)})</span>
)}
<Download className="w-3 h-3 text-muted-foreground shrink-0" />
</a>
)}
<button
onClick={() => handleRemoveFile(idx)}
className="inline-flex items-center justify-center w-6 h-6 rounded relative after:absolute after:-inset-1 after:content-[''] md:opacity-0 md:group-hover/file:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
title="Удалить файл"
data-testid={`file-clear-${field.code}-${idx}`}
>
<X className="w-3 h-3" />
</button>
</span>
);
})}
</div>
</>
) : (
<span className="text-muted-foreground text-sm" data-testid={`file-empty-${field.code}`}>Нет файла</span>
)}
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileChange}
aria-label={`Загрузить файл для поля ${field.name}`}
/>
{viewerFile && (
<FileViewerModal
url={viewerFile.url}
name={viewerFile.name}
onClose={() => setViewerFile(null)}
/>
)}
</div>
);
}
// Вспомогательная функция: получить значение из данных 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<string, unknown>;
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<GeoValue | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
// Двойное касание на мобильных — вход в редактирование (как до per-field рефакторинга)
const lastTapRef = useRef<number>(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<unknown>(initialValue);
const editValueRef = useRef<unknown>(initialValue);
const [localError, setLocalError] = useState<string | null>(null);
// Очередь обновлений autofill-полей, которые нужно сохранить вместе с основным полем
const pendingAutofillRef = useRef<Array<{ fieldId: number; value: string }>>([]);
// Флаги составных редакторов (сохранение по закрытию — клик вне строки)
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<string, unknown>)
: {};
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<string, unknown> | 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<string, unknown> | null | undefined) => {
// Optimistic-обновление кэша детали задачи: read-view меняется мгновенно, без мерцания
const prevDetail = queryClient.getQueryData<TaskDetailCacheShape>(['/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 (
<span
className={cn(
'rounded px-1.5 py-0.5 text-sm font-mono border inline-flex items-center gap-1',
isError ? 'bg-warning border-warning-foreground/30 text-warning-foreground' : 'bg-muted border-border'
)}
data-testid={`text-custom-field-${field.code}`}
>
{isError ? (
<span
title={isCircular ? 'Циклическая зависимость: это поле формулы ссылается само на себя через цепочку других формул' : undefined}
>
{String(result)}
</span>
) : result === '' ? (
<span className="text-muted-foreground">—</span>
) : (
<span>{String(result)}</span>
)}
</span>
);
}
if (initialValue === null || initialValue === undefined || initialValue === '') {
return <span data-testid={`text-custom-field-${field.code}`} className="text-muted-foreground">—</span>;
}
if (field.type === 'task' && field.linkedFormId) {
const taskIdVal = typeof initialValue === 'boolean' ? String(initialValue) : initialValue;
return (
<span data-testid={`text-custom-field-${field.code}`}>
<LinkedTaskDisplay linkedFormId={field.linkedFormId} taskId={taskIdVal} relationType={field.taskRelationType} />
</span>
);
}
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 (
<span data-testid={`text-custom-field-${field.code}`}>
<TableValueDisplay tableId={opts.tableId} displayColumn={opts.displayColumn} rowId={rowIdVal} />
</span>
);
}
}
if (field.type === 'company') {
return (
<span data-testid={`text-custom-field-${field.code}`} className="inline-flex items-center gap-1">
<Building2 className="w-3 h-3 text-muted-foreground" />
{initialValue.toString()}
</span>
);
}
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 <span className="text-muted-foreground">—</span>;
return (
<a
href="#"
onClick={(e) => { e.preventDefault(); setNavGeo(geo); }}
className="text-primary hover:underline inline-flex items-center gap-1 cursor-pointer"
>
<MapPin className="w-3 h-3" />
{display}
</a>
);
}
if (field.type === 'checkbox' || field.type === 'toggle') {
const checked = initialValue === true || initialValue === 'true';
return (
<span data-testid={`text-custom-field-${field.code}`}>{checked ? 'Да' : 'Нет'}</span>
);
}
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 (
<span data-testid={`text-custom-field-${field.code}`} className="text-sm">
Выполнено {checkedCount} из {totalCount}
</span>
);
}
if (field.type === 'date' || field.type === 'datetime') {
if (initialValue === '__auto_prolongation__') {
return <span data-testid={`text-custom-field-${field.code}`}>{field.autoProlongationLabel || 'Автопролонгация'}</span>;
}
if (initialValue) {
try {
const date = new Date(initialValue.toString());
if (!isNaN(date.getTime())) {
return <span data-testid={`text-custom-field-${field.code}`}>{date.toLocaleDateString('ru-RU')}</span>;
}
} catch (_e) { /* показываем как есть */ }
}
}
if (field.type === 'user') {
return (
<span data-testid={`text-custom-field-${field.code}`}>
<UserRoleRenderer field={field as any} value={initialValue} users={users} roles={roles} />
</span>
);
}
if (field.type === 'select' || field.type === 'radio-group') {
return <span data-testid={`text-custom-field-${field.code}`}>{getSelectOptionLabel(field.options, initialValue) || initialValue.toString()}</span>;
}
if (field.type === 'textarea' || field.type === 'rich-text') {
return (
<div data-testid={`text-custom-field-${field.code}`}>
<SafeHtmlRenderer html={initialValue.toString()} />
</div>
);
}
if (field.type === 'geo') {
if (!initialValue) return <span className="text-muted-foreground">—</span>;
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 (
<a
href="#"
onClick={(e) => { e.preventDefault(); setNavGeo(geo); }}
className="inline-flex items-center gap-1 text-primary hover:underline cursor-pointer"
data-testid={`text-custom-field-${field.code}`}
>
<MapPin className="w-3.5 h-3.5" />
{display}
</a>
);
}
if (field.type === 'contract-number' && initialValue) {
return (
<span data-testid={`text-custom-field-${field.code}`}>
<ContractNumberDisplay
value={initialValue}
legalEntityFormId={(field.options as any)?.contractNumber?.legalEntityFormId}
/>
</span>
);
}
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 <span className="text-muted-foreground">—</span>;
}
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 (
<div data-testid={`text-custom-field-${field.code}`} className="inline-flex flex-col text-sm">
{line1 && <span className="font-medium">{line1}</span>}
{line2 && <span className="text-muted-foreground">{line2}</span>}
</div>
);
}
return <span data-testid={`text-custom-field-${field.code}`} className={cn(field.type === 'number' && 'tabular-nums')}>{safeString(initialValue)}</span>;
};
// Рендер редактора по типу поля (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 (
<input
type="text"
inputMode={field.type === 'number' ? 'numeric' : undefined}
className={inputClass}
value={editValue === null || editValue === undefined ? '' : String(editValue)}
onChange={e => 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 (
<AutoResizeTextarea
className={cn(textareaClass, field.type === 'rich-text' && 'font-mono')}
minRows={2}
maxRows={25}
value={editValue === null || editValue === undefined ? '' : String(editValue)}
onChange={e => 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: <b>жирный</b>, <i>курсив</i>, <ul><li>список</li></ul>' : 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 (
<div className="flex items-center gap-1">
<input
type={isDateTime ? 'datetime-local' : 'date'}
className={cn(inputClass, 'w-auto min-w-[130px]')}
value={editValue ? String(editValue).slice(0, isDateTime ? 16 : 10) : ''}
onChange={e => {
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 ? (
<button
type="button"
title="Очистить"
className="inline-flex items-center justify-center w-6 h-6 rounded relative after:absolute after:-inset-2 after:content-[''] text-muted-foreground hover:text-destructive hover:bg-muted/50 transition-[color,background-color,transform] active:scale-[0.96]"
onClick={() => commitValue('')}
>
<X className="w-3.5 h-3.5" />
</button>
) : null}
</div>
);
}
// Дата/время с автопролонгацией: составной редактор, сохранение по закрытию (клик вне / Enter)
if (field.type === 'date' || field.type === 'datetime') {
const isProlongation = editValue === '__auto_prolongation__';
return (
<div className="flex items-center gap-2 flex-wrap" onClick={e => e.stopPropagation()}>
<input
type={field.type === 'datetime' ? 'datetime-local' : 'date'}
className={cn(inputClass, 'w-auto min-w-[130px]')}
value={isProlongation ? '' : (editValue ? String(editValue).slice(0, field.type === 'datetime' ? 16 : 10) : '')}
onChange={e => 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(); }
}}
/>
<label className="flex items-center space-x-2 text-sm cursor-pointer shrink-0">
<input
type="checkbox"
checked={isProlongation}
onChange={(e) => updateEditValue(e.target.checked ? '__auto_prolongation__' : '')}
onClick={(e) => e.stopPropagation()}
/>
<span>{field.autoProlongationLabel || 'Автопролонгация'}</span>
</label>
</div>
);
}
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<string>();
return (
<div className="flex-1 min-w-0 max-w-[320px]" onClick={e => e.stopPropagation()}>
<SearchableMultiSelect
size="detail"
options={opts.map((o) => ({ value: o.value, label: o.label }))}
selected={selected}
onChange={(next) => {
const arr = Array.from(next);
updateEditValue(arr);
commitValue(arr, { close: false });
}}
/>
</div>
);
}
// Одиночный выбор: сохранение сразу по выбору
return (
<select
className={cn(inputClass, 'w-auto')}
value={editValue === null || editValue === undefined ? '' : String(editValue)}
onChange={e => commitValue(e.target.value)}
onClick={e => e.stopPropagation()}
autoFocus
aria-label={field.name}
aria-invalid={!!effectiveError}
aria-describedby={effectiveError ? fieldErrorId : undefined}
onKeyDown={e => {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); }
}}
>
<option value="">—</option>
{opts.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
);
}
// Чек-лист: сохранение по каждому переключению, выход — клик вне / 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 (
<div className="flex-1 min-w-0 max-h-64 overflow-y-auto space-y-1 py-0.5" onClick={e => 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 (
<label key={itemId} className="flex items-start space-x-2 cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<input
type="checkbox"
className="rounded border-input mt-0.5"
checked={isChecked}
onChange={(e) => {
const newValue = items.map((x: unknown, i: number) => {
const xi = x as { id?: string; value?: string };
const id = xi.id || xi.value || String(i);
const existing = valueArr.find((v) => (v.itemId || v.id) === id);
return { itemId: id, checked: id === itemId ? e.target.checked : !!existing?.checked };
});
updateEditValue(newValue);
commitValue(newValue, { close: false });
}}
/>
<span className="flex-1 text-[13px] leading-snug">
{itemLabel}
{it.period && <span className="text-xs text-muted-foreground ml-1">({it.period})</span>}
</span>
</label>
);
})}
</div>
);
}
// Пользователь/роль: сохранение сразу по выбору (radix Select);
// мультивыбор — сохранение по каждому переключению, выход — клик вне / Esc
if (field.type === 'user') {
return (
<div onClick={e => e.stopPropagation()}>
<UserRoleEditor
field={field as any}
value={editValue as string | number | null | undefined}
onChange={(val) => { if (field.allowMultiple) updateEditValue(val); commitValue(val, { close: !field.allowMultiple }); }}
users={users}
roles={roles}
autoOpen
/>
</div>
);
}
// Гео: составной редактор, сохранение по закрытию
if (field.type === 'geo') {
return (
<div className="flex-1 min-w-0" onClick={e => e.stopPropagation()}>
<GeoField
size="detail"
value={editValue as unknown as GeoValue | string | null}
onChange={val => updateEditValue(val)}
/>
</div>
);
}
// Компания: выбор подсказки сохраняет сразу (с autofill), закрытие — сохраняет введённый текст
if (field.type === 'company') {
return (
<div className="w-auto max-w-[420px]" onClick={e => e.stopPropagation()}>
<CompanyFieldInput
size="detail"
value={editValue === null || editValue === undefined ? '' : String(editValue)}
onChange={val => 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: '' }] : [];
});
}
}}
/>
</div>
);
}
// Номер договора: составной редактор, сохранение по закрытию
if (field.type === 'contract-number') {
const cnConfig = (field.options as any)?.contractNumber;
return (
<div className="flex items-center gap-1.5" onClick={e => e.stopPropagation()}>
<ContractNumberField
value={editValue as any}
onChange={(val) => updateEditValue(JSON.stringify(val))}
config={cnConfig}
formId={formId}
fieldId={field.id}
compact
/>
</div>
);
}
if (field.type === 'table') {
const showSubdirectory = isTableSubdirectory;
// Подсправочник: составной редактор, сохранение по закрытию
if (showSubdirectory) {
return (
<div className="flex-1 min-w-0" onClick={e => e.stopPropagation()}>
<TableFieldWithSubdirectory
fieldId={field.id}
fieldCode={field.code}
placeholder={field.placeholder}
options={field.options}
value={editValue}
onChange={(val) => updateEditValue(val)}
allowMultiple={field.allowMultiple || false}
size="detail"
/>
</div>
);
}
// Мультивыбор строк: сохранение по каждому переключению
if (field.allowMultiple) {
return (
<div className="w-auto max-w-[420px]" onClick={e => e.stopPropagation()}>
<TableFieldMultiSelect
fieldId={field.id}
fieldCode={field.code}
placeholder={field.placeholder}
options={field.options}
value={editValue}
size="detail"
onChange={(val) => {
updateEditValue(val);
commitValue(val, { close: false });
}}
/>
</div>
);
}
// Одиночный выбор строки: сохранение сразу по выбору
return (
<div className="w-auto max-w-[420px]" onClick={e => e.stopPropagation()}>
<TableFieldSelect
fieldId={field.id}
fieldCode={field.code}
placeholder={field.placeholder}
options={field.options}
value={editValue}
size="detail"
onChange={(val) => commitValue(val)}
/>
</div>
);
}
// Контакт: составной редактор, сохранение по закрытию
if (field.type === 'contact') {
return (
<div className="flex-1 min-w-0" onClick={e => e.stopPropagation()}>
<ContactField
size="detail"
value={editValue as any}
onChange={(val) => updateEditValue(JSON.stringify(val))}
/>
</div>
);
}
// Связанная задача: сохранение сразу по выбору
if (field.type === 'task' && field.linkedFormId) {
return (
<div className="w-auto max-w-[420px]" onClick={e => e.stopPropagation()}>
<TaskFieldSelect
formField={field as any}
value={editValue as string | number | null | undefined}
onChange={(val) => commitValue(val)}
showCreateButton={false}
size="detail"
/>
</div>
);
}
return null;
};
// Кнопка — всегда read-only (своя логика действий)
if (field.type === 'button') {
return <ButtonField field={field} taskId={taskId} allFieldValues={allFieldValues} allFields={allFields} />;
}
// Файл — своя логика загрузки
if (field.type === 'file') {
return <FileFieldRow field={field} value={initialValue} taskId={taskId} formId={formId} />;
}
// Шаблон документа — выбор шаблона и генерация
if (field.type === 'document_template') {
return (
<DocumentTemplateField
field={field as any}
value={initialValue}
taskId={taskId}
onChange={(val) => 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 ? (
<label className="inline-flex items-center gap-1.5 cursor-pointer select-none" data-testid={`text-custom-field-${field.code}`}>
<input
type="checkbox"
className="w-4 h-4 rounded border border-input text-primary focus:ring-ring disabled:opacity-50"
checked={initialValue === true || initialValue === 'true'}
disabled={!canEdit}
onChange={(e) => commitValue(e.target.checked, { close: false })}
onClick={(e) => e.stopPropagation()}
aria-label={field.name}
aria-invalid={!!effectiveError}
aria-describedby={effectiveError ? fieldErrorId : undefined}
/>
<span className="text-[13px] leading-5">
{initialValue === true || initialValue === 'true' ? 'Да' : 'Нет'}
</span>
</label>
) : isEditing && editorNode != null ? (
<div className="flex-1 min-w-0 animate-in fade-in-0 duration-150">
{editorNode}
</div>
) : (
<div
className={cn('min-w-0 text-[13px] leading-5', canEdit && 'cursor-text')}
title={canEdit ? 'Двойной клик для редактирования' : undefined}
onDoubleClick={(e) => {
if (!canEdit) return;
if (isInteractiveClickTarget(e.target)) return;
e.stopPropagation();
enterEdit();
}}
onTouchEnd={handleDoubleTap}
>
{renderReadValue()}
</div>
);
return (
<>
<div
ref={wrapperRef}
className={cn(
'group/field flex gap-1 min-h-[36px] md:min-h-[var(--field-row-min,28px)] py-0.5 -mx-1 px-1 rounded transition-colors hover:bg-muted/50',
isLabelTop ? 'items-start' : 'items-center',
rowClassName
)}
>
{/* Левая колонка: индикаторы обязательности / неизменяемости / карандаш */}
<div className="w-6 h-5 shrink-0 flex items-center justify-center">
{isFieldPending ? (
<span className="inline-flex items-center justify-center w-5 h-5" title="Изменение ожидает синхронизации">
<Clock className="w-3.5 h-3.5 text-warning-foreground animate-pulse" />
</span>
) : fieldState.immutable ? (
<button
type="button"
onClick={enterEdit}
className="inline-flex items-center justify-center w-8 h-8 md:w-5 md:h-5 rounded text-destructive hover:bg-destructive/10 cursor-not-allowed"
title="Редактирование запрещено в текущем статусе"
>
<PencilOff className="w-3.5 h-3.5" />
</button>
) : fieldState.required ? (
<span
className="inline-flex items-center justify-center w-5 h-5 text-destructive font-bold"
title="Обязательное поле"
>
*
</span>
) : (
/* Карандаш: на мобильном всегда виден, на десктопе — по hover строки или фокусу */
isEditable && !readOnly && !isEditing && (
<button
type="button"
onClick={enterEdit}
className="inline-flex items-center justify-center w-8 h-8 md:w-5 md:h-5 rounded text-muted-foreground/60 hover:text-foreground hover:bg-muted/60 transition-all active:scale-[0.96] md:opacity-0 md:group-hover/field:opacity-100 md:focus-visible:opacity-100"
title="Редактировать"
aria-label={`Редактировать поле «${field.name}»`}
>
<Pencil className="w-3 h-3" />
</button>
)
)}
</div>
{/* Контент поля: read-view и редактор занимают один и тот же слот (без оверлеев) */}
<div className="flex-1 min-w-0">
{isLabelTop ? (
<>
{/* Вертикальная раскладка (label.position === 'top'): label-ряд сверху h-5, значение снизу */}
<div className="h-5 flex items-center">
<strong className={cn(fieldLabelClass, labelClassName, effectiveError && 'text-destructive')}>
{field.name}
</strong>
</div>
<div className="flex items-center gap-1.5 flex-wrap min-h-[var(--field-row-min,28px)]">
{valueNode}
</div>
</>
) : (
<div className="flex items-center gap-1.5 flex-wrap">
<strong className={cn('shrink-0 text-[13px] leading-5', labelClassName, effectiveError && 'text-destructive')}>
{field.name}:
</strong>
{valueNode}
</div>
)}
{isFieldPending && (
<span
className="ml-1.5 inline-flex items-center gap-0.5 text-xs text-warning-foreground font-medium"
title="Изменение будет отправлено при восстановлении соединения"
>
<Clock className="w-2.5 h-2.5" />
ожидает
</span>
)}
{effectiveError && (
<span id={fieldErrorId} role="alert" className="ml-1.5 text-xs text-destructive">{effectiveError}</span>
)}
</div>
</div>
<GeoNavigationPicker
geo={navGeo}
open={!!navGeo}
onOpenChange={(open) => { if (!open) setNavGeo(null); }}
/>
</>
);
}