1607 lines
70 KiB
TypeScript
1607 lines
70 KiB
TypeScript
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); }}
|
||
/>
|
||
</>
|
||
);
|
||
}
|