Files
iistwin/client/src/pages/TaskDetail.tsx
Ильяс Султанов d35d0cd1ef fix: textarea раскрывается по высоте текста, файлы горизонтально
- textarea overlay max-height увеличен до min(85vh, 700px)
- убрана правая кнопка 'Добавить файл' (дубль левой иконки)
- файлы идут горизонтально, длинные имена обрезаются ellipsis, список занимает доступную ширину
2026-07-14 19:50:32 +03:00

4230 lines
183 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, useCallback } from 'react';
import { useParams, useLocation } from 'wouter';
import { useQuery, useMutation } from '@tanstack/react-query';
import Layout from '@/components/Layout';
import { Sidebar } from '@/components/Sidebar';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
import { Checkbox } from '@/components/ui/checkbox';
import { Textarea } from '@/components/ui/textarea';
import { AutoResizeTextarea } from '@/components/ui/auto-resize-textarea';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, MessageSquare, ChevronLeft, ChevronRight, ListTree, Link2, Table, ExternalLink, PlusCircle, LinkIcon, Building2, Bell, Trash2, Plus, Search, X, Users, User, History, Check, Pencil, PencilOff, Maximize2, Minimize2, Upload, FileText, Download, Clock, Lock, AlertCircle, Shield, UserCheck, UserX, CheckCircle, MapPin, Navigation, FileSignature, EyeOff, Eye } from 'lucide-react';
import { Link } from 'wouter';
import { apiRequest, queryClient, OfflineQueuedError, ConflictError, BadRequestError, taskUpdateRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { cn } from '@/lib/utils';
import { format } from 'date-fns';
import { formatOrg, parseOrgDateTime } from '@/lib/timezone';
import { addFileToken, openFilePresigned } from '@/lib/fileUtils';
import { normalizeSelectOptions, getSelectOptionLabel } from '@/lib/fieldOptions';
import { SearchableMultiSelect } from '@/components/fields/SearchableMultiSelect';
import TaskChat from '../components/TaskChat';
import { JsComponentRenderer } from '@/components/tabs/JsComponentRenderer';
import { FileViewerModal, detectViewerType } from '@/components/FileViewerModal';
import { RelatedTasksTab } from '@/components/RelatedTasksTab';
import { TaskHistory } from '@/components/TaskHistory';
import TaskRegularTable from '@/components/TaskRegularTable';
import { SafeHtmlRenderer } from '@/components/SafeHtmlRenderer';
import GeoField, { type GeoValue } from '@/components/GeoField';
import GeoNavigationPicker from '@/components/GeoNavigationPicker';
import { DeclarativeTabRenderer } from '@/components/tabs/DeclarativeTabRenderer';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { useIsMobile } from '@/hooks/use-mobile';
import { useAuth } from '@/hooks/useAuth';
import { isAdmin } from '@/lib/permissions';
import { useEvents } from '@/hooks/useEvents';
import { useOfflineSyncContext } from '@/contexts/OfflineSyncContext';
import { useTaskWithCache } from '@/hooks/useTaskWithCache';
import type { CachedTaskDetail } from '@/hooks/useTaskWithCache';
import { saveTask, getTask, getDirectory, saveDirectory, patchTaskCache } from '@/lib/tasksCache';
import type { TableColumnDef, ReminderRecipient, Task } from '@shared/schema';
import { evaluateAllFormulas, FORMULA_ERROR, FORMULA_CIRCULAR_ERROR } from '@/lib/formulaEngine';
import CompanyFieldInput, { type DadataCompany } from '@/components/CompanyFieldInput';
import ContractNumberField from '@/components/ContractNumberField';
import ContractNumberDisplay from '@/components/ContractNumberDisplay';
import TableFieldSelect from '@/components/TableFieldSelect';
import TableFieldMultiSelect from '@/components/TableFieldMultiSelect';
import ContactField from '@/components/ContactField';
import { UserRoleRenderer, UserRoleEditor } from '@/components/fields';
import { InlineField } from '@/components/fields/InlineField';
import { TaskFieldSelect } from '@/components/task-form-fields';
import { TaskDocumentButton } from '@/components/tasks/TaskDocumentButton';
import { TaskQrButton } from '@/components/TaskQrButton';
import { DocumentTemplateField } from '@/components/fields/DocumentTemplateField';
import {
validateRequiredFields,
validateRequiredFieldsDetailed,
computeFieldState,
buildRequiredConditionContext,
buildClientFieldValues,
getAllConditionFields,
type FieldConditionContext,
type ClientFormField,
type FieldState,
} from '@/lib/fieldConditions';
const SYSTEM_TAB_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
subtasks: ListTree,
related: LinkIcon,
chat: MessageSquare,
history: History,
access: Shield,
};
const SYSTEM_TAB_LABELS: Record<string, string> = {
subtasks: 'Подзадачи',
related: 'Связанные',
chat: 'Чат',
history: 'История',
access: 'Доступы',
};
interface TabModuleInfo {
id?: number;
type: string;
label: string;
icon: string;
isBuiltIn: boolean;
hasFile: boolean;
config?: any;
inputSchema?: Array<{ name: string; label: string; type: string; required?: boolean; default?: string | number; placeholder?: string; description?: string }>;
isActive?: boolean;
}
interface FormData {
id: number;
name: string;
description?: string;
isActive: boolean;
chatLayout?: 'left' | 'tab';
chatEnabled?: boolean;
systemFieldsTabId?: number | null;
defaultTabId?: number | null; // null = first tab, -1 = 'chat', число = id вкладки
detailLayoutCode?: string | null;
hiddenSystemFields?: string[] | null;
systemTabOrder?: string[] | null;
}
interface FormTab {
id: number;
formId: number;
name: string;
code: string;
type: string;
position: number;
tableColumns?: TableColumnDef[];
}
interface FormFieldData {
id: number;
name: string;
code: string;
type: string;
isRequired: boolean;
placeholder?: string;
options?: unknown;
defaultValue?: string;
validationRules?: any;
requiredRules?: {
enabled?: boolean;
statusId?: number | "__creation__" | null;
logic?: "and" | "or";
conditions?: Array<{ fieldCode: string; operator?: "contains" | "empty" | "filled"; value?: string }>;
} | null;
immutableRules?: {
enabled?: boolean;
statusId?: number | "__creation__" | null;
logic?: "and" | "or";
conditions?: Array<{ fieldCode: string; operator?: "contains" | "empty" | "filled"; value?: string }>;
} | null;
visibilityRules?: {
enabled?: boolean;
statusId?: number | "__creation__" | null;
logic?: "and" | "or";
conditions?: Array<{ fieldCode: string; operator?: "contains" | "empty" | "filled"; value?: string }>;
} | null;
tabId?: number | null;
linkedFormId?: number | null;
taskRelationType?: 'parent' | 'child' | null;
buttonActionType?: 'open_url' | 'create_task' | 'generate_document' | null;
buttonUrl?: string | null;
buttonFormId?: number | null;
companyAutofill?: Record<string, string>;
maxFileCount?: number | null;
maxFileSizeMB?: number | null;
autoProlongation?: boolean;
autoProlongationLabel?: string;
allowMultiple?: boolean;
}
interface TaskData {
id: number;
title: string;
description?: string;
currentStatusId: number;
assignedTo?: number;
createdBy: number;
createdAt: string;
updatedAt: string;
completedAt?: string;
isCompleted?: boolean;
dueDate?: string;
parentTaskId?: number | null;
depth?: number;
}
interface TaskFieldValueData {
id: number;
taskId: number;
fieldId: number;
value: string | number | boolean | null;
}
interface TaskFieldValuesResponse {
fieldValues: TaskFieldValueData[];
}
interface FormStatus {
id: number;
formId: number;
name: string;
color: string;
position: number;
isInitial?: boolean;
isFinal?: boolean;
}
// Компонент для отображения значения связанной задачи
function LinkedTaskDisplay({ linkedFormId, taskId, relationType }: { linkedFormId: number; taskId: string | number; relationType?: 'parent' | 'child' | null }) {
const { isOnline } = useOfflineSyncContext();
const [offlineTask, setOfflineTask] = useState<{ id: number; title: string } | null>(null);
const { data: tasksData, isLoading } = useQuery<{ tasks: Array<{ id: number; title: string }> }>({
queryKey: ['/api/forms', linkedFormId, 'tasks'],
enabled: !!linkedFormId && !!taskId && isOnline,
staleTime: 5 * 60 * 1000,
});
// Offline fallback: resolve task title from IDB task-cache
useEffect(() => {
const numId = Number(taskId);
if (!isOnline && !tasksData && numId) {
getTask(numId).then(cached => {
if (cached?.data && typeof cached.data === 'object') {
const d = cached.data as { task?: { id: number; title: string } };
if (d.task) setOfflineTask({ id: d.task.id, title: d.task.title });
}
});
}
if (isOnline) setOfflineTask(null);
}, [isOnline, tasksData, taskId]);
if (isLoading && isOnline) return <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-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200">
Родитель
</span>
)}
{relationType === 'child' && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-200">
Дочерняя
</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 }) {
const { isOnline } = useOfflineSyncContext();
const [offlineData, setOfflineData] = useState<DirectoryData | null>(null);
const { data: tableData, isLoading } = useQuery<DirectoryData>({
queryKey: ['/api/directories', tableId],
enabled: !!tableId && !!rowId && 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 && rowId) {
getDirectory(tableId).then(cached => {
if (cached) setOfflineData(cached.data as DirectoryData);
}).catch(() => {});
}
}, [isOnline, tableData, tableId, rowId]);
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>;
const row = table.rows?.find(r => r.id.toString() === rowId?.toString());
if (!row) return <span className="text-muted-foreground">Запись не найдена</span>;
let displayColumnIndex = table.columns?.findIndex(c => c.name === displayColumn);
if (displayColumnIndex === undefined || displayColumnIndex < 0) {
displayColumnIndex = 0;
}
return (
<span className="inline-flex items-center gap-1">
<Table className="w-3 h-3 text-muted-foreground" />
{row.values[displayColumnIndex] || row.values[0]}
</span>
);
}
// Компонент для рендеринга кнопки с действием
function ButtonField({ field, taskId }: { field: FormFieldData; taskId?: number }) {
const [, setLocation] = useLocation();
const handleButtonClick = () => {
if (field.buttonActionType === 'open_url' && field.buttonUrl) {
window.open(field.buttonUrl, '_blank', 'noopener,noreferrer');
} else if (field.buttonActionType === 'create_task' && field.buttonFormId) {
setLocation(`/forms/${field.buttonFormId}/tasks/new`);
} else if (field.buttonActionType === 'generate_document' && taskId) {
const opts = (field.options || {}) as {
documentTemplateId?: number;
generateFormat?: 'docx' | 'pdf' | 'both';
generateReturnTo?: string;
};
const templateId = opts.documentTemplateId;
if (!templateId) return;
const format = opts.generateFormat || 'both';
const returnTo = opts.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 && (field.options as any)?.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>
</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']);
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="opacity-0 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 '';
}
}
// Компонент для отображения одного поля с поддержкой inline-редактирования
function FieldValueRow({
field,
value: initialValue,
task,
taskId,
formId,
users,
roles,
allFieldValues,
allFields,
statuses,
isPending,
readOnly,
errorMessage,
onErrorClear,
isGlobalEditMode,
draftValue,
onDraftChange,
onRequestGlobalEditMode,
}: {
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;
isGlobalEditMode?: boolean;
draftValue?: string | number | boolean | string[] | number[] | null | undefined;
onDraftChange?: (value: string | number | boolean | string[] | number[] | null | undefined) => void;
onRequestGlobalEditMode?: () => void;
}) {
const { toast } = useToast();
// Вычисляем условное состояние поля (required/immutable/visible) для отображения меток и валидации
const fieldState = useMemo(() => {
if (!allFields) return { required: false, immutable: false, visible: true };
const fieldValuesArray = allFields.map((f) => ({ fieldId: f.id, value: allFieldValues?.[f.code] }));
const allConditionFields = getAllConditionFields(allFields as ClientFormField[]);
const context = buildRequiredConditionContext(users as any, roles || [], false, statuses);
const taskForConditions = task ?? null;
return computeFieldState(
field as ClientFormField,
taskForConditions,
buildClientFieldValues(taskForConditions, fieldValuesArray, statuses),
allConditionFields,
context
);
}, [field, task, allFieldValues, allFields, users, roles, statuses]);
const isEditable = INLINE_EDITABLE_TYPES.has(field.type);
const [navGeo, setNavGeo] = useState<GeoValue | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
// Очередь обновлений autofill-полей, которые нужно сохранить вместе с основным полем
const pendingAutofillRef = useRef<Array<{ fieldId: number; value: string }>>([]);
// В глобальном режиме редактирования используем черновое значение из TaskDetail,
// иначе показываем исходное значение.
const editorValue = isGlobalEditMode ? draftValue : initialValue;
// Двойное касание на мобильных устройствах
const lastTapRef = useRef<number>(0);
const handleTouchEnd = (e: React.TouchEvent) => {
const now = Date.now();
if (now - lastTapRef.current < 300) {
handleEnterEdit(e);
}
lastTapRef.current = now;
};
const isInteractiveTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return false;
return !!target.closest('a, button, input, select, textarea, [role="button"]');
};
const handleEnterEdit = (e?: React.MouseEvent | React.TouchEvent) => {
e?.stopPropagation();
if (isGlobalEditMode) return;
if (e && isInteractiveTarget(e.target)) return;
if (!isEditable || readOnly) return;
if (fieldState.immutable) {
toast({
title: 'Редактирование недоступно',
description: `Поле «${field.name}» не может быть изменено в текущем статусе`,
variant: 'destructive',
});
return;
}
onRequestGlobalEditMode?.();
};
const saveValue = async (val: string | number | boolean | Record<string, unknown> | null | undefined) => {
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;
}
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="rounded px-1.5 py-0.5 text-sm font-mono bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 inline-flex items-center gap-1"
data-testid={`text-custom-field-${field.code}`}
>
{isCircular ? (
<span
className="text-orange-600 dark:text-orange-400"
title="Циклическая зависимость: это поле формулы ссылается само на себя через цепочку других формул"
>
{String(result)}
</span>
) : isError ? (
<span className="text-red-600 dark:text-red-400">{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-blue-600 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-blue-600 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}`}>{safeString(initialValue)}</span>;
};
// Рендер редактора по типу поля
const renderEditor = () => {
const inputClass = cn(
'border border-input rounded px-1.5 py-0.5 text-sm bg-background focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-[280px]',
errorMessage && 'border-destructive ring-destructive'
);
const textareaClass = cn(
'border border-input rounded px-1.5 py-0.5 text-sm bg-background focus:outline-none focus:ring-1 focus:ring-ring flex-1 min-w-0',
errorMessage && 'border-destructive ring-destructive'
);
if (field.type === 'text' || field.type === 'number') {
return (
<input
type="text"
inputMode={field.type === 'number' ? 'numeric' : undefined}
className={inputClass}
value={editorValue === null || editorValue === undefined ? '' : String(editorValue)}
onChange={e => onDraftChange?.(field.type === 'number' ? e.target.value.replace(/\s/g, '') : e.target.value)}
onClick={e => e.stopPropagation()}
autoFocus
aria-invalid={!!errorMessage}
onKeyDown={e => {
if (e.key === 'Escape') { onDraftChange?.(initialValue); }
}}
/>
);
}
if (field.type === 'textarea' || field.type === 'rich-text') {
return (
<AutoResizeTextarea
className={cn(textareaClass, 'font-mono text-xs min-h-full')}
minRows={2}
maxRows={25}
value={editorValue === null || editorValue === undefined ? '' : String(editorValue)}
onChange={e => onDraftChange?.(e.target.value)}
onClick={e => e.stopPropagation()}
autoFocus
aria-invalid={!!errorMessage}
placeholder={field.type === 'rich-text' ? 'HTML: <b>жирный</b>, <i>курсив</i>, <ul><li>список</li></ul>' : undefined}
onKeyDown={e => {
if (e.key === 'Escape') { onDraftChange?.(initialValue); }
}}
/>
);
}
if (field.type === 'date') {
const isProlongation = editorValue === '__auto_prolongation__';
return (
<div className="space-y-1">
<input
type="date"
className={inputClass}
value={isProlongation ? '' : (editorValue ? String(editorValue).split('T')[0] : '')}
onChange={e => onDraftChange?.(e.target.value)}
onClick={e => e.stopPropagation()}
autoFocus
disabled={isProlongation}
aria-invalid={!!errorMessage}
/>
{field.autoProlongation && (
<label className="flex items-center space-x-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={isProlongation}
onChange={(e) => onDraftChange?.(e.target.checked ? '__auto_prolongation__' : '')}
onClick={(e) => e.stopPropagation()}
/>
<span>{field.autoProlongationLabel || 'Автопролонгация'}</span>
</label>
)}
</div>
);
}
if (field.type === 'datetime') {
const isProlongation = editorValue === '__auto_prolongation__';
return (
<div className="space-y-1">
<input
type="datetime-local"
className={inputClass}
value={isProlongation ? '' : (editorValue ? String(editorValue).slice(0, 16) : '')}
onChange={e => onDraftChange?.(e.target.value)}
onClick={e => e.stopPropagation()}
autoFocus
disabled={isProlongation}
aria-invalid={!!errorMessage}
/>
{field.autoProlongation && (
<label className="flex items-center space-x-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={isProlongation}
onChange={(e) => onDraftChange?.(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);
if (field.allowMultiple) {
const selected = Array.isArray(editorValue)
? new Set(editorValue.map((v: any) => String(v)))
: editorValue != null && editorValue !== ''
? new Set([String(editorValue)])
: new Set<string>();
return (
<div className="w-full max-w-[320px]" onClick={e => e.stopPropagation()}>
<SearchableMultiSelect
options={opts.map((o) => ({ value: o.value, label: o.label }))}
selected={selected}
onChange={(next) => onDraftChange?.(Array.from(next))}
/>
</div>
);
}
return (
<select
className={cn(inputClass, 'w-auto')}
value={editorValue === null || editorValue === undefined ? '' : String(editorValue)}
onChange={e => onDraftChange?.(e.target.value)}
onClick={e => e.stopPropagation()}
autoFocus
aria-invalid={!!errorMessage}
>
<option value="">—</option>
{opts.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
);
}
if (field.type === 'checkbox' || field.type === 'toggle') {
return (
<input
type="checkbox"
className="w-4 h-4 rounded border border-input text-primary focus:ring-ring"
checked={editorValue === true || editorValue === 'true'}
onChange={(e) => onDraftChange?.(e.target.checked)}
onClick={(e) => e.stopPropagation()}
autoFocus
aria-invalid={!!errorMessage}
/>
);
}
if (field.type === 'user') {
return (
<div onClick={e => e.stopPropagation()}>
<UserRoleEditor
field={field as any}
value={editorValue}
onChange={(val) => onDraftChange?.(val)}
users={users}
roles={roles}
/>
</div>
);
}
if (field.type === 'geo') {
return (
<div className="w-full max-w-[320px]" onClick={e => e.stopPropagation()}>
<GeoField
value={editorValue as unknown as GeoValue | string | null}
onChange={val => onDraftChange?.(val as unknown as string | number | boolean | null | undefined)}
/>
</div>
);
}
if (field.type === 'company') {
return (
<div className="w-full max-w-[280px]" onClick={e => e.stopPropagation()}>
<CompanyFieldInput
value={editorValue === null || editorValue === undefined ? '' : String(editorValue)}
onChange={val => onDraftChange?.(val)}
onSelect={company => {
onDraftChange?.(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) }] : [];
});
}
saveValue(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="w-full max-w-[320px]" onClick={e => e.stopPropagation()}>
<ContractNumberField
value={editorValue as any}
onChange={(val) => onDraftChange?.(JSON.stringify(val))}
config={cnConfig}
formId={formId}
fieldId={field.id}
/>
</div>
);
}
if (field.type === 'table') {
return (
<div className="w-full max-w-[280px]" onClick={e => e.stopPropagation()}>
{field.allowMultiple ? (
<TableFieldMultiSelect
fieldId={field.id}
fieldCode={field.code}
placeholder={field.placeholder}
options={field.options}
value={editorValue}
onChange={(val) => onDraftChange?.(val)}
/>
) : (
<TableFieldSelect
fieldId={field.id}
fieldCode={field.code}
placeholder={field.placeholder}
options={field.options}
value={editorValue}
onChange={(val) => onDraftChange?.(val)}
/>
)}
</div>
);
}
if (field.type === 'contact') {
return (
<div className="w-full max-w-[320px]" onClick={e => e.stopPropagation()}>
<ContactField
value={editorValue as any}
onChange={(val) => onDraftChange?.(JSON.stringify(val))}
/>
</div>
);
}
if (field.type === 'task' && field.linkedFormId) {
return (
<div className="w-full max-w-[320px]" onClick={e => e.stopPropagation()}>
<TaskFieldSelect
formField={field as any}
value={editorValue}
onChange={(val) => onDraftChange?.(val)}
showCreateButton={false}
/>
</div>
);
}
return null;
};
// Кнопка — всегда read-only (своя логика действий)
if (field.type === 'button') {
return <ButtonField field={field} taskId={taskId} />;
}
// Файл — своя логика загрузки
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;
return (
<>
<div
ref={wrapperRef}
className="group/field flex items-start gap-1 py-0.5 -mx-1 px-1 rounded transition-colors hover:bg-muted/20"
onDoubleClick={handleEnterEdit}
onTouchEnd={handleTouchEnd}
>
{/* Левая колонка: индикаторы обязательности/неизменяемости */}
<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-amber-500 dark:text-amber-400 animate-pulse" />
</span>
) : fieldState.immutable ? (
<button
type="button"
onClick={handleEnterEdit}
className="inline-flex items-center justify-center w-5 h-5 rounded text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/50 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-red-500 dark:text-red-400 font-bold"
title="Обязательное поле"
>
*
</span>
) : null}
</div>
{/* Контент поля */}
<div className="flex-1 min-w-0">
<div className="flex items-start gap-1 flex-wrap">
<strong className={cn('shrink-0', errorMessage && 'text-destructive')}>
{field.name}:
</strong>
{(() => {
const readViewNode = renderReadValue();
const editorNode = renderEditor();
const canShowInlineEditor = isGlobalEditMode && isEditable && !fieldState.immutable && !readOnly && editorNode != null;
return canShowInlineEditor ? (
<InlineField
readView={readViewNode}
editor={editorNode}
isEditing={true}
editable={isEditable && !readOnly && !fieldState.immutable}
onEnterEdit={handleEnterEdit}
fitOverlay={field.type !== 'textarea' && field.type !== 'rich-text'}
overlayClassName={field.type === 'textarea' || field.type === 'rich-text' ? 'flex flex-col overflow-hidden' : ''}
overlayMaxHeight={field.type === 'textarea' || field.type === 'rich-text' ? 'min(85vh, 700px)' : undefined}
overlayMinWidth={field.type === 'textarea' || field.type === 'rich-text' ? '100%' : 180}
overlayWidth={field.type === 'textarea' || field.type === 'rich-text' ? '100%' : undefined}
className={field.type === 'textarea' || field.type === 'rich-text' ? 'items-start min-h-[24px] w-full' : 'items-center min-h-[28px]'}
/>
) : readViewNode;
})()}
</div>
{isFieldPending && !isGlobalEditMode && (
<span
className="ml-1.5 inline-flex items-center gap-0.5 text-[10px] text-amber-600 dark:text-amber-400 font-medium"
title="Изменение будет отправлено при восстановлении соединения"
>
<Clock className="w-2.5 h-2.5" />
ожидает
</span>
)}
</div>
</div>
<GeoNavigationPicker
geo={navGeo}
open={!!navGeo}
onOpenChange={(open) => { if (!open) setNavGeo(null); }}
/>
</>
);
}
interface FormTransition {
id: number;
fromStatusId: number;
toStatusId: number;
rejectToStatusId?: number | null;
actionType: string;
actionName?: string | null;
approveButtonText?: string | null;
rejectButtonText?: string | null;
approveButtonColor?: string | null;
rejectButtonColor?: string | null;
requiresApproval?: boolean;
approverRoleId?: number | null;
assigneeUserId?: number | null;
resolvedAssigneeUserIds?: number[];
}
interface UserSummary {
id: number;
email: string;
firstName: string | null;
lastName: string | null;
middleName?: string | null;
fullName?: string;
}
interface TaskDetailResponse extends CachedTaskDetail {
task: TaskData;
form: FormData;
fields: FormFieldData[];
statuses: FormStatus[];
tabs: FormTab[];
transitions: FormTransition[];
fieldValues: TaskFieldValueData[];
subtasks: Array<TaskData & { formId?: number }>;
messages: unknown[];
users: UserSummary[];
hasRelations: boolean;
}
interface TabFieldsContentProps {
tabId?: number;
hasTabs: boolean;
fields: FormFieldData[];
fieldValues: TaskFieldValueData[];
taskIdNumber: number;
formId?: number;
task?: TaskData;
users: UserSummary[];
roles: Array<{ id: number; name: string }>;
fieldValuesMap: Record<string, unknown>;
conditionContextStatuses: Array<{ id: number; name?: string; position: number; isFinal?: boolean | null }>;
isTaskFinal: boolean;
isFieldVisibleForTask: (field: FormFieldData) => boolean;
isFieldImmutableForTask: (field: FormFieldData) => boolean;
fieldErrors: Record<number, string>;
setFieldErrors: React.Dispatch<React.SetStateAction<Record<number, string>>>;
isTaskEditMode?: boolean;
draftFieldValues: Record<number, string | number | boolean | string[] | number[] | null | undefined>;
onDraftChange?: (fieldId: number, value: string | number | boolean | string[] | number[] | null | undefined) => void;
onRequestGlobalEditMode?: () => void;
}
const TabFieldsContent = ({
tabId,
hasTabs,
fields,
fieldValues,
taskIdNumber,
formId,
task,
users,
roles,
fieldValuesMap,
conditionContextStatuses,
isTaskFinal,
isFieldVisibleForTask,
isFieldImmutableForTask,
fieldErrors,
setFieldErrors,
isTaskEditMode,
draftFieldValues,
onDraftChange,
onRequestGlobalEditMode,
}: TabFieldsContentProps) => {
const { isPending: isUrlPending, getPendingBody } = useOfflineSyncContext();
const visibleFields = fields.filter(
f => f.type !== 'company' && (tabId === undefined || f.tabId === tabId) && isFieldVisibleForTask(f)
);
return (
<div className="space-y-2 text-sm">
{visibleFields && visibleFields.length > 0 ? (
visibleFields.map((field) => {
const fieldValue = fieldValues.find((fv: TaskFieldValueData) => fv.fieldId === field.id);
const fieldUrl = `/api/tasks/${taskIdNumber}/field-values/${field.id}`;
const pendingBody = isUrlPending(fieldUrl) ? getPendingBody(fieldUrl) : null;
const effectiveValue = (pendingBody && 'value' in pendingBody)
? (pendingBody as { value: string | number | boolean | null | undefined }).value
: fieldValue?.value;
return (
<ErrorBoundary key={`eb-${field.id}`}>
<FieldValueRow
key={field.id}
field={field}
task={task}
value={effectiveValue}
taskId={taskIdNumber}
formId={formId}
users={users}
roles={roles}
allFieldValues={fieldValuesMap}
allFields={fields}
statuses={conditionContextStatuses}
isPending={isUrlPending}
readOnly={isTaskFinal}
errorMessage={fieldErrors[field.id]}
onErrorClear={() => setFieldErrors((prev) => { const next = { ...prev }; delete next[field.id]; return next; })}
isGlobalEditMode={isTaskEditMode}
draftValue={draftFieldValues[field.id] ?? effectiveValue}
onDraftChange={(val) => onDraftChange?.(field.id, val)}
onRequestGlobalEditMode={onRequestGlobalEditMode}
/>
</ErrorBoundary>
);
})
) : (
hasTabs && tabId !== undefined ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-xs">На этой вкладке нет полей</p>
<p className="text-[10px] mt-1">Назначьте поля на вкладку в редакторе формы</p>
</div>
) : null
)}
</div>
);
};
interface TaskRoleData {
id: number;
taskId: number;
roleId: number;
organizationId: number;
createdAt: string;
role: { id: number; name: string; parentId: number | null };
}
interface TaskAccessRecord {
id: number;
taskId: number;
userId: number;
grantedVia: string;
roleId: number | null;
firstInteractionAt: string;
revokedAt: string | null;
user: { id: number; firstName: string | null; lastName: string | null; middleName?: string | null; email: string };
}
interface TransitionApprovalEntry {
id: number;
changedBy: number | null;
changedByName: string | null;
createdAt: string;
metadata: {
statusName?: string;
roleName?: string | null;
roleId?: number | null;
statusId?: number;
actionText?: string;
} | null;
}
interface OrgRole {
id: number;
name: string;
description?: string | null;
parentId?: number | null;
}
function TaskAccessTab({ taskId, isAdmin }: { taskId: number; isAdmin: boolean }) {
const { toast } = useToast();
const [roleSearch, setRoleSearch] = useState('');
const [roleDropdownOpen, setRoleDropdownOpen] = useState(false);
const { data: rolesData, refetch: refetchRoles } = useQuery<{ success: boolean; roles: TaskRoleData[] }>({
queryKey: ['/api/tasks', taskId, 'roles'],
enabled: !!taskId,
staleTime: 30 * 1000,
});
const { data: accessData, refetch: refetchAccess } = useQuery<{
success: boolean;
roles: Array<{ roleId: number; roleName: string; parentId: number | null; users: TaskAccessRecord[] }>;
directUsers: TaskAccessRecord[];
access: TaskAccessRecord[];
}>({
queryKey: ['/api/tasks', taskId, 'access'],
enabled: !!taskId && isAdmin,
staleTime: 30 * 1000,
});
const { data: auditLogData } = useQuery<{ success: boolean; log: Array<{ id: number; action: string; changedBy: number | null; changedByName: string | null; createdAt: string; metadata: Record<string, unknown> | null }> }>({
queryKey: ['/api/tasks', taskId, 'audit-log'],
enabled: !!taskId,
staleTime: 30 * 1000,
});
const { data: orgRolesData } = useQuery<{ success: boolean; roles: OrgRole[] }>({
queryKey: ['/api/roles'],
enabled: isAdmin,
staleTime: 60 * 1000,
});
const assignedRoles = rolesData?.roles || [];
const accessRolesTree = accessData?.roles || [];
const directUsers = accessData?.directUsers || [];
const allAccessRecords = accessData?.access || [];
const orgRoles = orgRolesData?.roles || [];
const assignedRoleIds = new Set(assignedRoles.map(r => r.roleId));
const transitionApprovals = (auditLogData?.log ?? []).filter(e => e.action === 'transition_approved') as TransitionApprovalEntry[];
const addRoleMutation = useMutation({
mutationFn: (roleId: number) => apiRequest('POST', `/api/tasks/${taskId}/roles`, { roleId }),
onSuccess: () => {
refetchRoles();
toast({ title: 'Роль добавлена' });
setRoleDropdownOpen(false);
setRoleSearch('');
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : 'Произошла ошибка';
toast({ title: 'Ошибка', description: msg, variant: 'destructive' });
},
});
const removeRoleMutation = useMutation({
mutationFn: (roleId: number) => apiRequest('DELETE', `/api/tasks/${taskId}/roles/${roleId}`),
onSuccess: () => {
refetchRoles();
toast({ title: 'Роль удалена' });
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : 'Произошла ошибка';
toast({ title: 'Ошибка', description: msg, variant: 'destructive' });
},
});
const revokeAccessMutation = useMutation({
mutationFn: (userId: number) => apiRequest('DELETE', `/api/tasks/${taskId}/access/${userId}`),
onSuccess: () => {
refetchAccess();
toast({ title: 'Доступ отозван' });
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : 'Произошла ошибка';
toast({ title: 'Ошибка', description: msg, variant: 'destructive' });
},
});
const filteredOrgRoles = orgRoles.filter(r => !assignedRoleIds.has(r.id) && r.name.toLowerCase().includes(roleSearch.toLowerCase()));
const getUserDisplayName = (user: { id: number; firstName: string | null; lastName: string | null; middleName?: string | null; email: string }) => {
const name = `${user.firstName || ''} ${user.middleName || ''} ${user.lastName || ''}`.trim();
return name || user.email;
};
const grantedViaLabel = (via: string) => {
if (via === 'role') return 'через роль';
if (via === 'supervisor') return 'руководитель';
if (via === 'mention') return 'упоминание';
return 'напрямую';
};
return (
<div className="space-y-6">
{/* Assigned Roles Section */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium flex items-center gap-1.5">
<Shield className="w-4 h-4 text-muted-foreground" />
Назначенные роли
</h3>
{isAdmin && (
<div className="relative">
<button
onClick={() => setRoleDropdownOpen(v => !v)}
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
<Plus className="w-3 h-3" />
Добавить роль
</button>
{roleDropdownOpen && (
<div className="absolute right-0 top-6 z-50 bg-popover border rounded shadow-md w-56 p-1">
<div className="flex items-center gap-1 border rounded px-2 h-7 mb-1 bg-background">
<Search className="w-3 h-3 text-muted-foreground shrink-0" />
<input
autoFocus
type="text"
value={roleSearch}
onChange={e => setRoleSearch(e.target.value)}
placeholder="Поиск ролей..."
className="flex-1 text-xs bg-transparent outline-none"
/>
</div>
<div className="max-h-40 overflow-y-auto">
{filteredOrgRoles.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-1.5">Нет доступных ролей</div>
) : (
filteredOrgRoles.map(role => (
<button
key={role.id}
onClick={() => addRoleMutation.mutate(role.id)}
disabled={addRoleMutation.isPending}
className="w-full text-left px-2 py-1.5 text-xs rounded hover:bg-accent"
>
{role.name}
{role.description && <span className="text-muted-foreground ml-1 text-[10px]">— {role.description}</span>}
</button>
))
)}
</div>
</div>
)}
</div>
)}
</div>
{assignedRoles.length === 0 ? (
<p className="text-xs text-muted-foreground italic">Роли не назначены</p>
) : (
<div className="flex flex-wrap gap-1.5">
{assignedRoles.map(tr => (
<span key={tr.id} className="inline-flex items-center gap-1 bg-muted border rounded px-2 py-0.5 text-xs">
<Users className="w-3 h-3 text-muted-foreground" />
{tr.role.name}
{isAdmin && (
<button
onClick={() => removeRoleMutation.mutate(tr.roleId)}
disabled={removeRoleMutation.isPending}
className="ml-0.5 text-muted-foreground hover:text-destructive"
title="Убрать роль"
>
<X className="w-2.5 h-2.5" />
</button>
)}
</span>
))}
</div>
)}
</div>
{/* User Access Records — tree structure (roles → users + direct) */}
{isAdmin && (
<div>
<h3 className="text-sm font-medium flex items-center gap-1.5 mb-2">
<UserCheck className="w-4 h-4 text-muted-foreground" />
Доступ пользователей
<span className="text-xs font-normal text-muted-foreground">({allAccessRecords.length})</span>
</h3>
{allAccessRecords.length === 0 ? (
<p className="text-xs text-muted-foreground italic">Нет записей доступа</p>
) : (
<div className="space-y-3">
{/* Role-based access groups */}
{accessRolesTree.map(roleGroup => (
<div key={roleGroup.roleId} className="border rounded p-2">
<div className="flex items-center gap-1.5 mb-1.5">
<Users className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-xs font-semibold">{roleGroup.roleName}</span>
{roleGroup.parentId && (
<span className="text-[10px] text-muted-foreground italic">(руководитель)</span>
)}
<span className="text-[10px] text-muted-foreground">· {roleGroup.users.length} польз.</span>
</div>
{roleGroup.users.length === 0 ? (
<p className="text-[10px] text-muted-foreground pl-5 italic">Нет взаимодействий</p>
) : (
<div className="space-y-0.5 pl-5">
{roleGroup.users.map(record => {
const isRevoked = !!record.revokedAt;
return (
<div key={record.id} className={`flex items-center justify-between gap-2 text-xs ${isRevoked ? 'opacity-50' : ''}`}>
<span className="truncate">{getUserDisplayName(record.user)}</span>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-[10px] text-muted-foreground">
{grantedViaLabel(record.grantedVia)} · {new Date(record.firstInteractionAt).toLocaleDateString('ru-RU')}
{isRevoked && <span className="text-destructive ml-1">· отозван</span>}
</span>
{!isRevoked && (
<button onClick={() => revokeAccessMutation.mutate(record.userId)} disabled={revokeAccessMutation.isPending} className="text-muted-foreground hover:text-destructive" title="Отозвать доступ">
<UserX className="w-3 h-3" />
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
))}
{/* Direct access (no specific role) */}
{directUsers.length > 0 && (
<div className="border rounded p-2">
<div className="flex items-center gap-1.5 mb-1.5">
<UserCheck className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-xs font-semibold">Прямой доступ</span>
<span className="text-[10px] text-muted-foreground">· {directUsers.length} польз.</span>
</div>
<div className="space-y-0.5 pl-5">
{directUsers.map(record => {
const isRevoked = !!record.revokedAt;
return (
<div key={record.id} className={`flex items-center justify-between gap-2 text-xs ${isRevoked ? 'opacity-50' : ''}`}>
<span className="truncate">{getUserDisplayName(record.user)}</span>
<div className="flex items-center gap-1.5 shrink-0">
{record.grantedVia === 'mention' && !isRevoked && (
<span className="inline-flex items-center px-1 py-0.5 rounded text-[10px] font-medium bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200">Упоминание</span>
)}
<span className="text-[10px] text-muted-foreground">
{new Date(record.firstInteractionAt).toLocaleDateString('ru-RU')}
{isRevoked && <span className="text-destructive ml-1">· отозван</span>}
</span>
{!isRevoked && (
<button onClick={() => revokeAccessMutation.mutate(record.userId)} disabled={revokeAccessMutation.isPending} className="text-muted-foreground hover:text-destructive" title="Отозвать доступ">
<UserX className="w-3 h-3" />
</button>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
)}
{/* Transition Approvals Section */}
{isAdmin && transitionApprovals.length > 0 && (
<div>
<h3 className="text-sm font-medium flex items-center gap-1.5 mb-2">
<CheckCircle className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
Согласования переходов
<span className="text-xs font-normal text-muted-foreground">({transitionApprovals.length})</span>
</h3>
<div className="space-y-1">
{transitionApprovals.map(entry => {
const meta = entry.metadata || {};
const statusName = meta.statusName ? String(meta.statusName) : '?';
const roleName = meta.roleName ? String(meta.roleName) : null;
return (
<div key={entry.id} className="border rounded p-2 flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs">
<span className="font-medium">{entry.changedByName || '—'}</span>
{roleName && (
<span className="text-muted-foreground"> · роль: <span className="font-medium text-foreground">{roleName}</span></span>
)}
<span className="text-muted-foreground"> → статус: <span className="font-medium text-foreground">{statusName}</span></span>
</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
{new Date(entry.createdAt).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hiddenSystemFields, users, isTaskFinal, isTaskEditMode, draftDueDate, onDraftDueDateChange, draftDueTime, onDraftDueTimeChange, onRequestGlobalEditMode }: { task: TaskData; taskId: number; formId?: number; getUserName: (id: number) => string; readOnly?: boolean; hiddenSystemFields?: string[] | null; users?: UserSummary[]; isTaskFinal?: boolean; isTaskEditMode?: boolean; draftDueDate?: string; onDraftDueDateChange?: (val: string) => void; draftDueTime?: string; onDraftDueTimeChange?: (val: string) => void; onRequestGlobalEditMode?: () => void }) {
const { toast } = useToast();
const { user: currentUser } = useAuth();
const isAdminUser = isAdmin(currentUser);
const { isPending: isTaskUrlPending, getPendingBody: getTaskPendingBody } = useOfflineSyncContext();
const taskUrl = `/api/tasks/${taskId}`;
const taskPendingBody = isTaskUrlPending(taskUrl) ? getTaskPendingBody(taskUrl) : null;
const wrapperRef = useRef<HTMLDivElement>(null);
const [assigneeDropdownOpen, setAssigneeDropdownOpen] = useState(false);
const [assigneeSearch, setAssigneeSearch] = useState('');
// Task roles (доступ по ролям)
const { data: taskRolesData, refetch: refetchTaskRoles } = useQuery<{ success: boolean; roles: TaskRoleData[] }>({
queryKey: ['/api/tasks', taskId, 'roles'],
enabled: !!taskId,
staleTime: 30 * 1000,
});
// Task assignees (несколько ответственных пользователей)
const { data: taskAssigneesData, refetch: refetchAssignees } = useQuery<{ success: boolean; assignees: Array<{ id: number; userId: number; user: { id: number; firstName: string | null; lastName: string | null; email: string } }> }>({
queryKey: ['/api/tasks', taskId, 'assignees'],
enabled: !!taskId,
staleTime: 30 * 1000,
});
const { data: orgRolesData } = useQuery<{ success: boolean; roles: OrgRole[] }>({
queryKey: ['/api/roles'],
enabled: isAdminUser,
staleTime: 60 * 1000,
});
const assignedTaskRoles = taskRolesData?.roles || [];
const assignedTaskUsers = taskAssigneesData?.assignees || [];
const orgRoles = orgRolesData?.roles || [];
const assignedRoleIds = new Set(assignedTaskRoles.map(r => r.roleId));
const assignedUserIds = new Set(assignedTaskUsers.map(a => a.userId));
const addAssigneeMutation = useMutation<{ success: boolean; task?: Task; autoTransition?: boolean }, Error, number>({
mutationFn: async (userId: number) => {
const res = await apiRequest('POST', `/api/tasks/${taskId}/assignees`, { userId });
return res.json();
},
onSuccess: (data) => {
// Merge authoritative task from server into cache immediately so status updates appear without refresh
const tid = Number(taskId || '0');
if (data?.task && tid) {
const prev = queryClient.getQueryData<TaskDetailResponse>(['/api/tasks', tid, 'detail']);
if (prev) {
const merged: TaskDetailResponse = { ...prev, task: { ...prev.task, ...(data.task as unknown as TaskData) } };
queryClient.setQueryData<TaskDetailResponse>(['/api/tasks', tid, 'detail'], merged);
saveTask(tid, merged).catch(() => {});
}
}
queryClient.invalidateQueries({ queryKey: ['/api/tasks', Number(taskId || '0'), 'detail'], refetchType: 'none' });
refetchAssignees();
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'assignees'] });
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'audit-log'] });
queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'tasks', 'with-fields'] });
queryClient.invalidateQueries({ queryKey: ['/api/home/inbox'] });
toast({ title: 'Ответственный добавлен' });
},
onError: (err: Error) => { const msg = err instanceof Error ? err.message : 'Произошла ошибка'; toast({ title: 'Ошибка', description: msg, variant: 'destructive' }); },
});
const removeAssigneeMutation = useMutation<{ success: boolean; task?: Task; autoTransition?: boolean }, Error, number>({
mutationFn: async (userId: number) => {
const res = await apiRequest('DELETE', `/api/tasks/${taskId}/assignees/${userId}`);
return res.json();
},
onSuccess: (data) => {
// Merge authoritative task from server into cache immediately so status updates appear without refresh
const tid = Number(taskId || '0');
if (data?.task && tid) {
const prev = queryClient.getQueryData<TaskDetailResponse>(['/api/tasks', tid, 'detail']);
if (prev) {
const merged: TaskDetailResponse = { ...prev, task: { ...prev.task, ...(data.task as unknown as TaskData) } };
queryClient.setQueryData<TaskDetailResponse>(['/api/tasks', tid, 'detail'], merged);
saveTask(tid, merged).catch(() => {});
}
}
queryClient.invalidateQueries({ queryKey: ['/api/tasks', Number(taskId || '0'), 'detail'], refetchType: 'none' });
refetchAssignees();
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'assignees'] });
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'audit-log'] });
queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'tasks', 'with-fields'] });
queryClient.invalidateQueries({ queryKey: ['/api/home/inbox'] });
toast({ title: 'Ответственный снят' });
},
onError: (err: Error) => { const msg = err instanceof Error ? err.message : 'Произошла ошибка'; toast({ title: 'Ошибка', description: msg, variant: 'destructive' }); },
});
const addRoleMutation = useMutation({
mutationFn: (roleId: number) => apiRequest('POST', `/api/tasks/${taskId}/roles`, { roleId }),
onSuccess: () => {
refetchTaskRoles();
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'roles'] });
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'audit-log'] });
toast({ title: 'Роль добавлена' });
setAssigneeDropdownOpen(false);
setAssigneeSearch('');
},
onError: (err: unknown) => { const msg = err instanceof Error ? err.message : 'Произошла ошибка'; toast({ title: 'Ошибка', description: msg, variant: 'destructive' }); },
});
const removeRoleMutation = useMutation({
mutationFn: (roleId: number) => apiRequest('DELETE', `/api/tasks/${taskId}/roles/${roleId}`),
onSuccess: () => {
refetchTaskRoles();
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'roles'] });
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'audit-log'] });
toast({ title: 'Роль удалена' });
},
onError: (err: unknown) => { const msg = err instanceof Error ? err.message : 'Произошла ошибка'; toast({ title: 'Ошибка', description: msg, variant: 'destructive' }); },
});
const filteredOrgRoles = orgRoles.filter(r => !assignedRoleIds.has(r.id) && r.name.toLowerCase().includes(assigneeSearch.toLowerCase()));
// Левая колонка: карандашик для назначения ответственного
const leftSlot = (onPencil: () => void) => (
<div className="w-6 h-5 shrink-0 flex items-center justify-center">
{!readOnly ? (
<button
type="button"
onClick={onPencil}
className="inline-flex items-center justify-center w-5 h-5 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
title="Редактировать"
>
<Pencil className="w-3.5 h-3.5" />
</button>
) : null}
</div>
);
const emptySlot = <div className="w-6 h-5 shrink-0" />;
const hidden = new Set(hiddenSystemFields ?? []);
// Пользователи ещё не назначенные — для выпадающего списка
const filteredUsers = (users ?? []).filter(u => {
const alreadyAssigned = assignedUserIds.has(u.id);
const matchesSearch = !assigneeSearch || (u.fullName || `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email)
.toLowerCase().includes(assigneeSearch.toLowerCase());
return !alreadyAssigned && matchesSearch;
});
// Имя ответственного пользователя
const getAssigneeName = (a: { user: { id: number; firstName: string | null; lastName: string | null; middleName?: string | null; email: string } }) =>
(`${a.user.firstName || ''} ${a.user.middleName || ''} ${a.user.lastName || ''}`.trim() || a.user.email);
// Показываем ли пустое состояние
const hasNoAssignees = assignedTaskUsers.length === 0 && assignedTaskRoles.length === 0;
return (
<div ref={wrapperRef} className="space-y-1 text-sm">
{/* Assignee + Roles */}
{!hidden.has('assignedTo') && (
<div className="group/sysrow flex items-start gap-1 py-0.5 -mx-1 px-1 rounded transition-colors hover:bg-muted/20">
{!readOnly
? leftSlot(() => { setAssigneeDropdownOpen(v => !v); setAssigneeSearch(''); })
: emptySlot}
<span className="text-muted-foreground w-22 shrink-0">Ответственный</span>
<div className="relative flex-1 min-w-0">
{/* Multiple assignees + roles as tags */}
<div className="flex flex-wrap gap-1 items-center min-h-[1.25rem]">
{hasNoAssignees && (
task.assignedTo
? <span className="inline-flex items-center gap-1 bg-muted border rounded px-1.5 py-0.5 text-xs">
<User className="w-3 h-3 text-muted-foreground" />
{getUserName(task.assignedTo)}
</span>
: !readOnly
? <span className="text-muted-foreground italic text-xs">Не назначен</span>
: null
)}
{assignedTaskUsers.map(a => (
<span key={`ua-${a.id}`} data-testid="text-task-assignee" className="inline-flex items-center gap-1 bg-muted border rounded px-1.5 py-0.5 text-xs">
<User className="w-3 h-3 text-muted-foreground" />
{getAssigneeName(a)}
{!readOnly && (
<button
onClick={() => removeAssigneeMutation.mutate(a.userId)}
disabled={removeAssigneeMutation.isPending}
className="ml-0.5 text-muted-foreground hover:text-destructive"
title="Снять ответственного"
>
<X className="w-2.5 h-2.5" />
</button>
)}
</span>
))}
{assignedTaskRoles.map(tr => (
<span key={`tr-${tr.id}`} className="inline-flex items-center gap-1 bg-muted border rounded px-1.5 py-0.5 text-xs">
<Users className="w-3 h-3 text-muted-foreground" />
{tr.role.name}
{isAdminUser && !readOnly && (
<button
onClick={() => removeRoleMutation.mutate(tr.roleId)}
disabled={removeRoleMutation.isPending}
className="ml-0.5 text-muted-foreground hover:text-destructive"
title="Убрать роль"
>
<X className="w-2.5 h-2.5" />
</button>
)}
</span>
))}
</div>
{/* Combined dropdown — unified search */}
{assigneeDropdownOpen && !readOnly && (
<div className="absolute left-0 top-6 z-50 bg-popover border rounded shadow-md w-56 p-1">
<div className="flex items-center gap-1 border rounded px-2 h-7 mb-1 bg-background">
<Search className="w-3 h-3 text-muted-foreground shrink-0" />
<input
autoFocus
type="text"
value={assigneeSearch}
onChange={e => setAssigneeSearch(e.target.value)}
placeholder="Поиск..."
className="flex-1 text-xs bg-transparent outline-none"
/>
</div>
<div className="max-h-60 overflow-y-auto">
{/* Users not yet assigned */}
{filteredUsers.length > 0 && (
<>
<div className="text-xs font-medium text-muted-foreground px-2 pt-1 pb-0.5">Пользователи</div>
{filteredUsers.map((u: any) => (
<button
key={`u-${u.id}`}
onMouseDown={e => e.preventDefault()}
onClick={() => addAssigneeMutation.mutate(u.id)}
disabled={addAssigneeMutation.isPending}
className="w-full text-left px-2 py-1.5 text-xs rounded hover:bg-accent disabled:opacity-50 flex items-center gap-1.5"
>
<User className="w-3 h-3 text-muted-foreground shrink-0" />
{u.fullName || `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email}
</button>
))}
</>
)}
{/* Roles — admin only */}
{isAdminUser && filteredOrgRoles.length > 0 && (
<>
<div className="text-xs font-medium text-muted-foreground px-2 pt-1 pb-0.5">Роли</div>
{filteredOrgRoles.map(role => (
<button
key={`r-${role.id}`}
onMouseDown={e => e.preventDefault()}
onClick={() => addRoleMutation.mutate(role.id)}
disabled={addRoleMutation.isPending}
className="w-full text-left px-2 py-1.5 text-xs rounded hover:bg-accent flex items-center gap-1.5"
>
<Users className="w-3 h-3 text-muted-foreground shrink-0" />
{role.name}
</button>
))}
</>
)}
{filteredUsers.length === 0 && (!isAdminUser || filteredOrgRoles.length === 0) && (
<div className="text-xs text-muted-foreground px-2 py-1.5">Ничего не найдено</div>
)}
</div>
</div>
)}
</div>
</div>
)}
{/* Due Date */}
{!hidden.has('dueDate') && (() => {
const pendingDueDate = taskPendingBody && 'dueDate' in taskPendingBody
? (taskPendingBody.dueDate as string | null | undefined)
: undefined;
const effectiveDueDate = pendingDueDate !== undefined ? pendingDueDate : task.dueDate;
const isDuePending = pendingDueDate !== undefined;
const dueDateReadView = (
<span data-testid="text-task-due-date" className="inline-flex items-center gap-1">
{effectiveDueDate
? (() => {
const overdue = !readOnly && !isDuePending && new Date(effectiveDueDate) < new Date();
return (
<span className={`inline-flex items-center gap-1 ${overdue ? 'text-red-600 dark:text-red-500 font-medium' : ''}`}>
{(() => {
const isAllDay = /T00:00:00(\.000)?Z$/.test(effectiveDueDate);
const dateStr = formatOrg(effectiveDueDate, 'dd.MM.yyyy');
const timeStr = !isAllDay ? formatOrg(effectiveDueDate, 'HH:mm') : '';
return (
<>
{dateStr}
{timeStr && <span className="text-muted-foreground ml-1">{timeStr}</span>}
</>
);
})()}
{overdue && <AlertCircle className="w-3.5 h-3.5" />}
</span>
);
})()
: <span className="text-muted-foreground italic">Указать срок...</span>
}
{isDuePending && (
<span title="Изменение ожидает синхронизации">
<Clock className="w-2.5 h-2.5 text-amber-500 dark:text-amber-400 animate-pulse" />
</span>
)}
</span>
);
const dueDateEditor = (
<div className="flex items-center gap-1.5">
<input
type="date"
className="border rounded px-1.5 py-0.5 text-sm bg-background focus:outline-none focus:ring-1 focus:ring-ring"
value={draftDueDate ?? ''}
onChange={e => onDraftDueDateChange?.(e.target.value)}
autoFocus
/>
<input
type="time"
className="border rounded px-1.5 py-0.5 text-sm bg-background focus:outline-none focus:ring-1 focus:ring-ring w-[90px]"
value={draftDueTime ?? ''}
onChange={e => onDraftDueTimeChange?.(e.target.value)}
/>
</div>
);
return (
<div
className="group/sysrow flex items-center gap-1 py-0.5 -mx-1 px-1 rounded transition-colors hover:bg-muted/20"
>
{emptySlot}
<span className="text-muted-foreground w-22 shrink-0">Срок</span>
<InlineField
readView={dueDateReadView}
editor={dueDateEditor}
isEditing={isTaskEditMode}
editable={!readOnly}
onEnterEdit={() => onRequestGlobalEditMode?.()}
fitOverlay={false}
overlayMinWidth={260}
overlayClassName="overflow-visible"
className="items-center"
/>
</div>
);
})()}
{/* Created (read-only) */}
{!hidden.has('createdAt') && (
<div className="flex items-center gap-1 py-0.5">
{emptySlot}
<span className="text-muted-foreground w-22 shrink-0">Создана</span>
<span data-testid="text-task-created">
{task.createdAt
? new Date(task.createdAt).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
: ''}
</span>
</div>
)}
{/* Completed at (read-only, shown only when task is in final status) */}
{isTaskFinal && !hidden.has('completedAt') && (
<div className="flex items-center gap-1 py-0.5">
{emptySlot}
<span className="text-muted-foreground w-22 shrink-0">Завершена</span>
<span data-testid="text-task-completed">
{task.completedAt
? new Date(task.completedAt).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
: '—'}
</span>
</div>
)}
{/* Creator (read-only) */}
{!hidden.has('createdBy') && task.createdBy && (
<div className="flex items-center gap-1 py-0.5">
{emptySlot}
<span className="text-muted-foreground w-22 shrink-0">Автор</span>
<span data-testid="text-task-creator">{getUserName(task.createdBy)}</span>
</div>
)}
</div>
);
}
interface SubtasksTabContentProps {
subtasks: Array<TaskData & { formId?: number }>;
taskIdNumber: number;
formId: number;
formName?: string;
formNamesMap: Record<number, string>;
}
function SubtasksTabContent({
subtasks,
taskIdNumber,
formId,
formName,
formNamesMap,
}: SubtasksTabContentProps) {
const childrenMap = new Map<number, Array<TaskData & { formId?: number }>>();
subtasks.forEach(t => {
const pId = t.parentTaskId || 0;
if (!childrenMap.has(pId)) {
childrenMap.set(pId, []);
}
childrenMap.get(pId)!.push(t);
});
const sortChildren = (children: Array<TaskData & { formId?: number }>) => {
return [...children].sort((a, b) => a.id - b.id);
};
const renderNode = (nodeParentId: number, depth: number): JSX.Element[] => {
const children = childrenMap.get(nodeParentId) || [];
const sortedChildren = sortChildren(children);
return sortedChildren.flatMap(subtask => {
const subtaskFormId = subtask.formId || formId;
const paddingLeft = depth * 20;
const displayFormName = formNamesMap[subtaskFormId] || formName || '';
const showFormName = subtaskFormId !== formId;
const elements: JSX.Element[] = [
<Link key={subtask.id} href={`/forms/${subtaskFormId}/tasks/${subtask.id}`}>
<div
className="flex items-center gap-2 p-2 rounded-md hover:bg-muted cursor-pointer group"
style={{ paddingLeft: `${paddingLeft + 8}px` }}
data-testid={`subtask-item-${subtask.id}`}
>
{depth > 0 && (
<span className="text-muted-foreground mr-1">└</span>
)}
<span className="text-xs font-mono text-muted-foreground min-w-[4rem]">
#{subtask.id}
</span>
<span className="flex-1 text-sm truncate">{subtask.title}</span>
{showFormName && (
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{displayFormName}
</span>
)}
<ChevronRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</Link>
];
const childElements = renderNode(subtask.id, depth + 1);
return [...elements, ...childElements];
});
};
return (
<div className="space-y-3">
<div className="flex items-center gap-1.5">
<ListTree className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium">Подзадачи</h2>
<span className="text-xs text-muted-foreground">({subtasks.length})</span>
</div>
<div className="space-y-1">
{renderNode(taskIdNumber, 0)}
</div>
</div>
);
}
interface TaskContentProps {
selectedTabId: string;
taskIdNumber: number;
formId: number;
form?: FormData;
task: TaskData;
formTabs: FormTab[];
fields: FormFieldData[];
fieldValues: TaskFieldValueData[];
users: UserSummary[];
roles: OrgRole[];
statuses: FormStatus[];
fieldValuesMap: Record<string, unknown>;
conditionContextStatuses: Array<{ id: number; name?: string; position: number; isFinal?: boolean | null }>;
isTaskFinal: boolean;
isFieldVisibleForTask: (field: FormFieldData) => boolean;
isFieldImmutableForTask: (field: FormFieldData) => boolean;
fieldErrors: Record<number, string>;
setFieldErrors: React.Dispatch<React.SetStateAction<Record<number, string>>>;
modulesData?: { modules: TabModuleInfo[] };
subtasks: Array<TaskData & { formId?: number }>;
formNamesMap: Record<number, string>;
getUserName: (id: number) => string;
systemFieldsTabId?: number | null;
hasTabs: boolean;
isTaskEditMode?: boolean;
draftFieldValues: Record<number, string | number | boolean | string[] | number[] | null | undefined>;
onDraftFieldChange?: (fieldId: number, value: string | number | boolean | string[] | number[] | null | undefined) => void;
onRequestGlobalEditMode?: () => void;
draftDueDate?: string;
onDraftDueDateChange?: (val: string) => void;
draftDueTime?: string;
onDraftDueTimeChange?: (val: string) => void;
}
function TaskContent({
selectedTabId,
taskIdNumber,
formId,
form,
task,
formTabs,
fields,
fieldValues,
users,
roles,
statuses,
fieldValuesMap,
conditionContextStatuses,
isTaskFinal,
isFieldVisibleForTask,
isFieldImmutableForTask,
fieldErrors,
setFieldErrors,
modulesData,
subtasks,
formNamesMap,
getUserName,
systemFieldsTabId,
hasTabs,
isTaskEditMode,
draftFieldValues,
onDraftFieldChange,
onRequestGlobalEditMode,
draftDueDate,
onDraftDueDateChange,
draftDueTime,
onDraftDueTimeChange,
}: TaskContentProps) {
const currentTab = useMemo(() => {
if (
selectedTabId === 'subtasks' ||
selectedTabId === 'related' ||
selectedTabId === 'history' ||
selectedTabId === 'chat' ||
selectedTabId === 'access'
) {
return undefined;
}
const tabId = parseInt(selectedTabId);
return formTabs.find(t => t.id === tabId);
}, [selectedTabId, formTabs]);
const isTableTab = currentTab?.type === 'table';
const customModule = useMemo(() => {
if (!currentTab) return undefined;
return modulesData?.modules?.find(m => m.type === currentTab.type && !m.isBuiltIn);
}, [currentTab, modulesData]);
const isCustomModuleTab = !!customModule && !!customModule.config;
const hasCustomLayout = !!(form?.detailLayoutCode?.trim());
const customLayoutExtraCtx = useMemo(() => {
if (!hasCustomLayout || !form) return undefined;
return {
fields,
statuses,
users,
tabs: formTabs,
form: { id: form.id, name: form.name },
};
}, [hasCustomLayout, form, fields, statuses, users, formTabs]);
const shouldShowSystemFields = useMemo(() => {
if (!hasTabs) return true;
if (selectedTabId === 'chat' || selectedTabId === 'access') return false;
if (!systemFieldsTabId) return true;
return parseInt(selectedTabId) === systemFieldsTabId;
}, [hasTabs, selectedTabId, systemFieldsTabId]);
const taskFieldsTabId =
!hasTabs ||
selectedTabId === 'chat' ||
selectedTabId === 'subtasks' ||
selectedTabId === 'related' ||
selectedTabId === 'history' ||
selectedTabId === 'access'
? undefined
: parseInt(selectedTabId);
return (
<>
{selectedTabId === 'subtasks' ? (
<SubtasksTabContent
subtasks={subtasks}
taskIdNumber={taskIdNumber}
formId={formId}
formName={form?.name}
formNamesMap={formNamesMap}
/>
) : selectedTabId === 'related' ? (
<RelatedTasksTab taskId={taskIdNumber} />
) : selectedTabId === 'history' ? (
<TaskHistory taskId={taskIdNumber} />
) : isCustomModuleTab && customModule && currentTab ? (
<DeclarativeTabRenderer
config={customModule.config}
inputSchema={customModule.inputSchema as any}
taskId={taskIdNumber}
tabId={currentTab.id}
/>
) : isTableTab && currentTab ? (
<TaskRegularTable
taskId={taskIdNumber}
tabId={currentTab.id}
columns={(currentTab.tableColumns as TableColumnDef[]) || []}
readOnly={isTaskFinal}
/>
) : hasCustomLayout ? (
<JsComponentRenderer
code={form!.detailLayoutCode!}
task={task as unknown as Record<string, unknown>}
fieldValues={fieldValuesMap}
extraCtx={customLayoutExtraCtx}
/>
) : (
<>
{shouldShowSystemFields && (
<InlineSystemFields
task={task}
taskId={taskIdNumber}
formId={formId}
getUserName={getUserName}
readOnly={isTaskFinal}
hiddenSystemFields={form?.hiddenSystemFields}
users={users}
isTaskEditMode={isTaskEditMode}
draftDueDate={draftDueDate}
onDraftDueDateChange={onDraftDueDateChange}
draftDueTime={draftDueTime}
onDraftDueTimeChange={onDraftDueTimeChange}
onRequestGlobalEditMode={onRequestGlobalEditMode}
/>
)}
<TabFieldsContent
tabId={taskFieldsTabId}
hasTabs={hasTabs}
fields={fields}
fieldValues={fieldValues}
taskIdNumber={taskIdNumber}
formId={formId}
task={task}
users={users}
roles={roles}
fieldValuesMap={fieldValuesMap}
conditionContextStatuses={conditionContextStatuses}
isTaskFinal={isTaskFinal}
isFieldVisibleForTask={isFieldVisibleForTask}
isFieldImmutableForTask={isFieldImmutableForTask}
fieldErrors={fieldErrors}
setFieldErrors={setFieldErrors}
isTaskEditMode={isTaskEditMode}
draftFieldValues={draftFieldValues}
onDraftChange={onDraftFieldChange}
onRequestGlobalEditMode={onRequestGlobalEditMode}
/>
</>
)}
</>
);
}
const TaskDetail = () => {
const { id, taskId } = useParams<{ id: string; taskId: string }>();
const [, setLocation] = useLocation();
const formId = parseInt(id || '0');
const taskIdNumber = parseInt(taskId || '0');
const [selectedTabId, setSelectedTabId] = useState<string>('');
const [fieldErrors, setFieldErrors] = useState<Record<number, string>>({});
const [expandedPane, setExpandedPane] = useState<'none' | 'chat' | 'task'>('none');
const [openAccordionValue, setOpenAccordionValue] = useState('');
const toggleExpand = (pane: 'chat' | 'task') =>
setExpandedPane(prev => prev === pane ? 'none' : pane);
const [initialTabSet, setInitialTabSet] = useState(false);
const [tabsScrollEl, setTabsScrollEl] = useState<HTMLDivElement | null>(null);
const tabsScrollRef = useCallback((el: HTMLDivElement | null) => setTabsScrollEl(el), []);
const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(false);
useEffect(() => {
if (!tabsScrollEl) return;
const update = () => {
setShowLeftArrow(tabsScrollEl.scrollLeft > 0);
setShowRightArrow(tabsScrollEl.scrollLeft < tabsScrollEl.scrollWidth - tabsScrollEl.clientWidth - 1);
};
update();
tabsScrollEl.addEventListener('scroll', update);
const observer = new ResizeObserver(update);
observer.observe(tabsScrollEl);
return () => {
tabsScrollEl.removeEventListener('scroll', update);
observer.disconnect();
};
}, [tabsScrollEl]);
useEffect(() => {
if (!tabsScrollEl) return;
const btn = tabsScrollEl.querySelector(`[data-testid="tab-btn-${selectedTabId}"]`);
if (btn) {
(btn as HTMLElement).scrollIntoView({ inline: 'nearest', behavior: 'smooth', block: 'nearest' });
}
}, [selectedTabId, tabsScrollEl]);
const isMobile = useIsMobile();
const { toast } = useToast();
const { user: currentUser } = useAuth();
const { isPending: isUrlPending, isOnline, pendingUrls, getPendingBody } = useOfflineSyncContext();
// Single source of truth: persists live data to IDB, falls back to IDB + form-cache when offline.
// This hook internally owns the useQuery for ['/api/tasks', taskIdNumber, 'detail'] — do NOT
// create a separate useQuery on the same key (it would be a duplicate pipeline).
const taskCache = useTaskWithCache<TaskDetailResponse>(taskIdNumber);
const effectiveDetailData = taskCache.data;
const cacheTimestamp = taskCache.cacheTimestamp;
const isUsingOfflineCache = taskCache.isFromCache;
const isLoading = taskCache.isLoading;
const taskError = taskCache.error;
// Redirect on 403 (no access to task)
useEffect(() => {
if (taskError && (taskError as any)?.status === 403) {
setLocation('/home');
}
}, [taskError, setLocation]);
// На мобильном разворачиваем первую пользовательскую вкладку по умолчанию
useEffect(() => {
const tabs = effectiveDetailData?.tabs || [];
if (openAccordionValue === '' && tabs.length > 0) {
setOpenAccordionValue(`tab-${tabs[0].id}`);
}
}, [effectiveDetailData?.tabs, openAccordionValue]);
const { data: hideStatus } = useQuery<{ success: boolean; isHidden: boolean }>({
queryKey: ['/api/tasks', taskId, 'hide-inbox'],
queryFn: () => apiRequest('GET', `/api/tasks/${taskIdNumber}/hide-inbox`).then(r => r.json()),
enabled: !!taskIdNumber,
staleTime: 30 * 1000,
});
const toggleHideMutation = useMutation({
mutationFn: async () => {
if (hideStatus?.isHidden) {
await apiRequest('DELETE', `/api/tasks/${taskIdNumber}/hide-inbox`);
} else {
await apiRequest('POST', `/api/tasks/${taskIdNumber}/hide-inbox`);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'hide-inbox'] });
queryClient.invalidateQueries({ queryKey: ['home'] });
queryClient.invalidateQueries({ queryKey: ['/api/home/inbox/count'] });
},
});
// Ref to track whether executeTransitionMutation is pending — used inside useEvents
// callback which is declared before the mutation, so we cannot reference it directly.
const transitionPendingRef = useRef(false);
// Mark notifications for this task as read — optimistically updates local cache
// immediately and syncs with the server. Used on mount and on SSE notification.
const markTaskNotificationsRead = useCallback(async () => {
if (!taskIdNumber) return;
// Optimistically mark matching notifications as read immediately
queryClient.setQueryData(['/api/notifications'], (old: any) => {
if (!old?.notifications) return old;
return {
...old,
notifications: old.notifications.map((n: any) =>
n.taskId === taskIdNumber ? { ...n, isRead: true } : n
),
};
});
try {
await apiRequest('PUT', `/api/tasks/${taskIdNumber}/notifications/read`);
queryClient.invalidateQueries({ queryKey: ['home'] });
queryClient.invalidateQueries({ queryKey: ['/api/home/inbox/count'] });
queryClient.invalidateQueries({ queryKey: ['/api/notifications'] });
} catch (err) {
console.error('[TaskDetail] Failed to mark notifications as read:', err);
// Rollback optimistic update on failure
queryClient.invalidateQueries({ queryKey: ['/api/notifications'] });
}
}, [taskIdNumber]);
// On mount: mark existing notifications for this task as read
useEffect(() => {
markTaskNotificationsRead();
}, [markTaskNotificationsRead]);
// Subscribe to SSE events for this specific task
useEvents({
onTaskUpdated: useCallback((data: { taskId: number; formId: number; task: any }) => {
if (data.taskId === taskIdNumber) {
// If a transition is in flight, the optimistic update already reflects the new status.
// Skip the immediate refetch to avoid a race where a proxy-cached response
// overwrites the optimistic value. The detail query was already marked stale
// in onSuccess; it will refetch on the next natural access.
if (transitionPendingRef.current) {
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskIdNumber, 'detail'], refetchType: 'none' });
} else {
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskIdNumber, 'detail'] });
}
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskIdNumber, 'audit-log'] });
// Keep offline task-cache fresh so subsequent offline edits have the correct updatedAt
if (data?.task) {
patchTaskCache(data.taskId, data.task).catch(() => {});
}
}
}, [taskIdNumber]),
onNotification: useCallback((data: { type: string; taskId?: number }) => {
if (data.taskId === taskIdNumber) {
// A new notification arrived for the currently open task — mark it read
// so the badge disappears without requiring the user to close/reopen.
markTaskNotificationsRead();
}
}, [taskIdNumber, markTaskNotificationsRead]),
});
// Count pending operations for this specific task (transitions, field edits, messages)
const taskPendingCount = useMemo(() => {
if (!taskIdNumber) return 0;
return Array.from(pendingUrls).filter(url => url.includes(`/api/tasks/${taskIdNumber}`)).length;
}, [pendingUrls, taskIdNumber]);
// True when there is a queued (not yet synced) transition request for this task
const isTransitionPending = pendingUrls.has(`/api/tasks/${taskIdNumber}/transition`);
// Глобальный режим редактирования задачи
const [isTaskEditMode, setIsTaskEditMode] = useState(false);
const [draftFieldValues, setDraftFieldValues] = useState<Record<number, string | number | boolean | string[] | number[] | null | undefined>>({});
const [draftDueDate, setDraftDueDate] = useState('');
const [draftDueTime, setDraftDueTime] = useState('');
const [draftTitle, setDraftTitle] = useState('');
const [isSavingBatch, setIsSavingBatch] = useState(false);
// Сбрасываем режим редактирования при смене задачи
useEffect(() => {
setIsTaskEditMode(false);
setDraftFieldValues({});
setDraftDueDate('');
setDraftDueTime('');
setDraftTitle('');
setFieldErrors({});
}, [taskIdNumber]);
// Delete task dialog state
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Reminder dialog state
const [showReminderDialog, setShowReminderDialog] = useState(false);
const [reminderDate, setReminderDate] = useState('');
const [reminderNote, setReminderNote] = useState('');
const [isReminderSubmitting, setIsReminderSubmitting] = useState(false);
// (debug error handlers removed)
const [deletingReminderId, setDeletingReminderId] = useState<number | null>(null);
const prevReminderDateRef = useRef('');
// Recipient multi-select state
type RecipientOption = { id: string; label: string; type: 'user' | 'role' | 'orgRole'; userId?: number; role?: string; roleId?: number };
const [selectedRecipients, setSelectedRecipients] = useState<RecipientOption[]>([]);
const [recipientSearch, setRecipientSearch] = useState('');
const [recipientDropdownOpen, setRecipientDropdownOpen] = useState(false);
const recipientInputRef = useRef<HTMLInputElement>(null);
const recipientContainerRef = useRef<HTMLDivElement>(null);
// Читаем параметр tab из URL (для открытия чата из push-уведомления)
const urlTabParam = new URLSearchParams(window.location.search).get('tab');
// Запрос кастомных модулей для рендеринга вкладок
const { data: modulesData } = useQuery<{ success: boolean; modules: TabModuleInfo[] }>({
queryKey: ['/api/tab-modules'],
staleTime: 60000,
});
// Загрузка списка форм для отображения названий подзадач из других форм
const { data: formsListData } = useQuery<{ forms: Array<{ id: number; name: string }> }>({
queryKey: ['/api/forms'],
staleTime: 5 * 60 * 1000,
});
// Запрос напоминаний задачи
const { data: remindersData } = useQuery<{ success: boolean; reminders: Array<{ id: number; remindAt: string; note?: string | null; recipients: ReminderRecipient[] }> }>({
queryKey: ['/api/tasks', taskIdNumber, 'reminders'],
enabled: !!taskIdNumber && showReminderDialog,
staleTime: 0,
});
const reminders = remindersData?.reminders || [];
// Прямой fetch без apiRequest/useMutation — гарантированно не зависает (таймаут 15с)
const directFetch = async (method: string, url: string, body?: any) => {
console.log('[reminder] directFetch START', method, url);
const headers: Record<string, string> = {};
if (body !== undefined) headers['Content-Type'] = 'application/json';
let res: Response;
try {
res = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
credentials: 'include',
signal: AbortSignal.timeout(15000),
// keepalive: true → отдельный пул соединений, обходит лимит HTTP/1.1
// на 6 параллельных соединений (которые забиты SSE-стримами)
keepalive: true,
});
console.log('[reminder] directFetch RESPONSE', res.status, url);
} catch (e: any) {
console.error('[reminder] directFetch ERROR', e?.name, e?.message, url);
if (e?.name === 'TimeoutError' || e?.name === 'AbortError') {
throw new Error('Сервер не отвечает (таймаут 15с). Попробуйте ещё раз.');
}
throw new Error(e?.message || 'Сетевая ошибка');
}
if (!res.ok) {
let msg = `Ошибка ${res.status}`;
try { const j = await res.json(); msg = j?.error || j?.message || msg; } catch {}
throw new Error(msg);
}
if (res.status === 204) return null;
try { return await res.json(); } catch { return null; }
};
const createReminder = async (data: { remindAt: string; note?: string; recipients: any[] }) => {
const result = await directFetch('POST', `/api/tasks/${taskIdNumber}/reminders`, data);
queryClient.invalidateQueries({ queryKey: ['home'] });
queryClient.invalidateQueries({ queryKey: ['/api/home/inbox/count'] });
return result;
};
const deleteReminder = async (reminderId: number) => {
setDeletingReminderId(reminderId);
try {
await directFetch('DELETE', `/api/reminders/${reminderId}`);
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskIdNumber, 'reminders'] });
queryClient.invalidateQueries({ queryKey: ['home'] });
queryClient.invalidateQueries({ queryKey: ['/api/home/inbox/count'] });
toast({ title: 'Напоминание удалено' });
} catch (err: any) {
toast({ title: 'Не удалось удалить напоминание', description: err?.message || 'Ошибка', variant: 'destructive' });
} finally {
setDeletingReminderId(null);
}
};
// Мутация выполнения перехода (согласование)
const executeTransitionMutation = useMutation<{ success: boolean; task: Task }, Error, { transitionId: number; action?: 'approve' | 'reject' }>({
mutationFn: async ({ transitionId, action }) => {
const payload: { transitionId: number; action?: string; timestamp: number } = { transitionId, timestamp: Date.now() };
if (action) payload.action = action;
const res = await apiRequest('POST', `/api/tasks/${taskIdNumber}/transition`, payload);
return res.json();
},
onMutate: () => {
// Сбрасываем подсветку ошибок полей при новой попытке перехода
setFieldErrors({});
},
onSuccess: (data) => {
// Переход выполнен — сбрасываем подсветку ошибок полей
setFieldErrors({});
// Merge the authoritative task from the server response into the cache
// so the UI keeps the correct status without waiting for a round-trip refetch.
// This prevents a stale proxy-cached GET from overwriting the optimistic update.
if (data?.task && taskIdNumber) {
const prev = queryClient.getQueryData<TaskDetailResponse>(['/api/tasks', taskIdNumber, 'detail']);
if (prev) {
const merged: TaskDetailResponse = { ...prev, task: { ...prev.task, ...(data.task as unknown as TaskData) } };
queryClient.setQueryData<TaskDetailResponse>(['/api/tasks', taskIdNumber, 'detail'], merged);
saveTask(taskIdNumber, merged).catch(() => {});
}
}
// Mark detail as stale so it will refetch on next natural access,
// but do NOT trigger an immediate refetch (avoids proxy-cached stale response overwriting the update).
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskIdNumber, 'detail'], refetchType: 'none' });
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskIdNumber] });
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskIdNumber, 'messages'] });
if (effectiveDetailData?.form?.id) {
queryClient.invalidateQueries({ queryKey: ['/api/forms', effectiveDetailData.form.id, 'tasks'] });
queryClient.refetchQueries({ queryKey: ['/api/forms', effectiveDetailData.form.id, 'tasks'] });
}
toast({ title: 'Успешно', description: 'Переход выполнен' });
},
onError: (error: Error, _vars, context) => {
// OfflineQueuedError = request was queued for later sync — keep the optimistic update
if (error instanceof OfflineQueuedError) return;
const ctx = context as { prev?: TaskDetailResponse } | undefined;
if (ctx?.prev) {
queryClient.setQueryData<TaskDetailResponse>(['/api/tasks', taskIdNumber, 'detail'], ctx.prev);
}
// Подсвечиваем незаполненные обязательные поля, если сервер вернул их список
if (error instanceof BadRequestError && error.serverData && typeof error.serverData === 'object') {
const serverFields = (error.serverData as { fields?: Array<{ fieldId?: number; message?: string }> }).fields;
if (Array.isArray(serverFields)) {
const next: Record<number, string> = {};
for (const f of serverFields) {
if (f.fieldId != null && f.message) {
next[f.fieldId] = f.message;
}
}
setFieldErrors(next);
}
}
toast({ variant: 'destructive', title: 'Ошибка', description: error instanceof Error ? error.message : 'Не удалось выполнить переход' });
},
});
// Мутация удаления задачи
const deleteTaskMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest('DELETE', `/api/tasks/${taskIdNumber}`);
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
if (effectiveDetailData?.form?.id) {
queryClient.invalidateQueries({ queryKey: ['/api/forms', effectiveDetailData.form.id, 'tasks'] });
}
toast({ title: 'Задача удалена' });
setShowDeleteDialog(false);
setLocation(`/forms/${effectiveDetailData?.form?.id || formId}/tasks`);
},
onError: (error: Error) => {
toast({ variant: 'destructive', title: 'Ошибка', description: error instanceof Error ? error.message : 'Не удалось удалить задачу' });
},
});
// Keep transitionPendingRef in sync so the SSE handler can check it without a stale closure
transitionPendingRef.current = executeTransitionMutation.isPending;
// Устанавливаем начальную вкладку на основе настроек формы или URL параметра
useEffect(() => {
if (!initialTabSet && effectiveDetailData?.form && effectiveDetailData?.tabs) {
const form = effectiveDetailData.form;
const tabs = effectiveDetailData.tabs;
// Приоритет URL параметру tab (для push-уведомлений)
if (urlTabParam === 'chat') {
setSelectedTabId('chat');
setInitialTabSet(true);
return;
}
if (tabs.length > 0) {
// defaultTabId: null = first tab, -1 = 'chat', число = id вкладки
if (form.defaultTabId === null || form.defaultTabId === undefined) {
setSelectedTabId(tabs[0].id.toString());
} else if (form.defaultTabId === -1) {
// На мобильном — чат-вкладка доступна; на десктопе — чат всегда виден сбоку, сбрасываем в первую вкладку
setSelectedTabId(isMobile ? 'chat' : tabs[0].id.toString());
} else {
// Проверяем что вкладка существует
const tabExists = tabs.some(t => t.id === form.defaultTabId);
if (tabExists) {
setSelectedTabId(form.defaultTabId.toString());
} else {
setSelectedTabId(tabs[0].id.toString());
}
}
}
setInitialTabSet(true);
}
}, [effectiveDetailData, initialTabSet, urlTabParam, isMobile]);
// На мобильном сбрасываем selectedTabId в 'task', если оно не 'chat'
useEffect(() => {
if (isMobile && selectedTabId !== 'chat' && selectedTabId !== 'task') {
setSelectedTabId('task');
}
}, [isMobile, selectedTabId]);
// Запрос ролей организации для диалога напоминаний (нужен всем, не только админам)
const { data: reminderOrgRolesData } = useQuery<{ success: boolean; roles: OrgRole[] }>({
queryKey: ['/api/roles'],
staleTime: 60 * 1000,
});
// Все опции для выбора получателей (должны быть до early return, чтобы не нарушать Rules of Hooks)
const allRecipientOptions = useMemo(() => {
type RecipientOpt = { id: string; label: string; type: 'user' | 'role' | 'orgRole'; userId?: number; role?: string; roleId?: number };
const opts: RecipientOpt[] = [];
if (currentUser) {
opts.push({ id: `user:${currentUser.id}`, label: `Я (${currentUser.firstName} ${currentUser.lastName})`, type: 'user', userId: currentUser.id });
}
opts.push({ id: 'role:admin', label: 'Все администраторы', type: 'role', role: 'admin' });
opts.push({ id: 'role:user', label: 'Все пользователи', type: 'role', role: 'user' });
// Добавляем кастомные роли организации
const orgRoles: OrgRole[] = reminderOrgRolesData?.roles || [];
for (const r of orgRoles) {
opts.push({ id: `orgRole:${r.id}`, label: `Роль: ${r.name}`, type: 'orgRole', roleId: r.id });
}
const allUsers: UserSummary[] = effectiveDetailData?.users || [];
for (const u of allUsers) {
if (u.id !== currentUser?.id) {
opts.push({ id: `user:${u.id}`, label: u.fullName || u.email, type: 'user', userId: u.id });
}
}
return opts;
}, [currentUser, effectiveDetailData, reminderOrgRolesData]);
// Инициализируем получателей и дату при открытии диалога
useEffect(() => {
if (showReminderDialog) {
// Сбрасываем локальный флаг отправки — гарантированно разблокирует кнопку
setIsReminderSubmitting(false);
setDeletingReminderId(null);
// Дата по умолчанию — завтра в 09:00
if (!reminderDate) {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0, 0);
const pad = (n: number) => String(n).padStart(2, '0');
const defaultDate = `${tomorrow.getFullYear()}-${pad(tomorrow.getMonth() + 1)}-${pad(tomorrow.getDate())}T09:00`;
setReminderDate(defaultDate);
}
if (currentUser && selectedRecipients.length === 0) {
setSelectedRecipients([
{ id: `user:${currentUser.id}`, label: `Я (${currentUser.firstName} ${currentUser.lastName})`, type: 'user', userId: currentUser.id },
]);
}
}
if (!showReminderDialog) {
setReminderDate('');
setSelectedRecipients([]);
setRecipientSearch('');
setRecipientDropdownOpen(false);
}
}, [showReminderDialog]);
// Фильтруем опции по поисковому запросу (исключая уже выбранные)
const filteredRecipientOptions = useMemo(() => {
const selectedIds = new Set(selectedRecipients.map(r => r.id));
const opts = allRecipientOptions.filter(o => !selectedIds.has(o.id));
if (!recipientSearch.trim()) return opts;
const q = recipientSearch.toLowerCase();
return opts.filter(o => o.label.toLowerCase().includes(q));
}, [allRecipientOptions, selectedRecipients, recipientSearch]);
// Коды полей, которые являются autofill-целями поля «компания»
// Должен быть до всех early return, чтобы не нарушать Rules of Hooks
const companyAutofillTargetCodes = useMemo(() => {
const allFields = effectiveDetailData?.fields || [];
const codes = new Set<string>();
allFields.forEach(f => {
if (f.type === 'company' && f.companyAutofill) {
Object.values(f.companyAutofill).forEach(code => {
if (code && code !== 'none') codes.add(code);
});
}
});
return codes;
}, [effectiveDetailData?.fields]);
// Маппинг: код autofill-цели → код поля ОГРН того же поля «компания».
// Используем, чтобы показывать и разрешать редактировать autofill-поля,
// когда у контрагента нет ОГРН (например, физлицо).
const autofillOgrnMap = useMemo(() => {
const allFields = effectiveDetailData?.fields || [];
const map: Record<string, string> = {};
allFields.forEach(f => {
if (f.type === 'company' && f.companyAutofill) {
const ogrnCode = f.companyAutofill['ogrn'];
if (!ogrnCode || ogrnCode === 'none') return;
Object.values(f.companyAutofill).forEach(code => {
if (code && code !== 'none' && code !== ogrnCode) {
map[code] = ogrnCode;
}
});
}
});
return map;
}, [effectiveDetailData?.fields]);
// Условные хуки полей должны быть ДО всех early return,
// иначе при переходе от экрана загрузки к данным ломается порядок хуков (белый экран).
const hookTaskCurrentStatusId = effectiveDetailData?.task?.currentStatusId ?? null;
const hookFieldValues = effectiveDetailData?.fieldValues || [];
const hookFields = effectiveDetailData?.fields || [];
const hookFormStatuses = effectiveDetailData?.statuses || [];
const hookUsers = effectiveDetailData?.users || [];
const rawFieldValuesForConditions = useMemo(
() => hookFieldValues.map((fv: TaskFieldValueData) => ({ fieldId: fv.fieldId, value: fv.value })),
[hookFieldValues]
);
const currentFieldValuesForConditions = useMemo(
() => buildClientFieldValues(
effectiveDetailData?.task || null,
rawFieldValuesForConditions,
hookFormStatuses
),
[effectiveDetailData?.task, rawFieldValuesForConditions, hookFormStatuses]
);
const conditionContextUsers = hookUsers as any;
const conditionContextRoles = reminderOrgRolesData?.roles || [];
const conditionContextStatuses = useMemo(
() => hookFormStatuses.map((s) => ({ id: s.id, name: s.name, position: s.position, isFinal: s.isFinal })),
[hookFormStatuses]
);
const allConditionFields = useMemo(() => getAllConditionFields(hookFields), [hookFields]);
const conditionContext = useMemo<FieldConditionContext>(() =>
buildRequiredConditionContext(conditionContextUsers, conditionContextRoles, false, conditionContextStatuses),
[conditionContextUsers, conditionContextRoles, conditionContextStatuses]
);
const getFieldStateForTask = useCallback(
(formField: FormFieldData): FieldState => {
return computeFieldState(
formField as ClientFormField,
{ currentStatusId: hookTaskCurrentStatusId },
currentFieldValuesForConditions,
allConditionFields as ClientFormField[],
conditionContext
);
},
[hookTaskCurrentStatusId, currentFieldValuesForConditions, allConditionFields, conditionContext]
);
// Переменные задачи должны быть доступны до early return, чтобы хуки редактирования
// (useCallback) всегда вызывались в одном и том же порядке (fix React error #310).
const fields = hookFields;
const fieldValues = hookFieldValues;
// Для autofill-полей «компании», у которых соответствующий ОГРН пуст
// (например, физлицо), форсируем видимость и снимаем immutable,
// чтобы можно было заполнить данные вручную.
const isAutofillTargetWithEmptyOgrn = useCallback(
(formField: FormFieldData) => {
const ogrnCode = autofillOgrnMap[formField.code];
if (!ogrnCode) return false;
const ogrnField = fields.find(f => f.code === ogrnCode);
if (!ogrnField) return false;
const ogrnValue = fieldValues.find((v: TaskFieldValueData) => v.fieldId === ogrnField.id)?.value;
return ogrnValue === null || ogrnValue === undefined || ogrnValue === '';
},
[autofillOgrnMap, fields, fieldValues]
);
const isFieldVisibleForTask = useCallback(
(formField: FormFieldData) => {
if (isAutofillTargetWithEmptyOgrn(formField)) return true;
return getFieldStateForTask(formField).visible;
},
[getFieldStateForTask, isAutofillTargetWithEmptyOgrn]
);
const isFieldImmutableForTask = useCallback(
(formField: FormFieldData) => {
if (isAutofillTargetWithEmptyOgrn(formField)) return false;
return getFieldStateForTask(formField).immutable;
},
[getFieldStateForTask, isAutofillTargetWithEmptyOgrn]
);
const task = effectiveDetailData?.task;
const users = hookUsers;
const formStatuses = hookFormStatuses;
const currentStatus = formStatuses.find(s => s.id === hookTaskCurrentStatusId);
const isTaskFinal = !!(currentStatus?.isFinal || task?.isCompleted);
// Сравнение значений полей: null/undefined/''/false считаем пустыми и равными друг другу
const valuesEqual = (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);
};
const enterTaskEditMode = useCallback(() => {
if (isTaskFinal || !task) return;
const next: Record<number, string | number | boolean | null | undefined> = {};
for (const f of fields) {
const fv = fieldValues.find((v: TaskFieldValueData) => v.fieldId === f.id);
next[f.id] = fv?.value ?? null;
}
setDraftFieldValues(next);
setDraftDueDate(task.dueDate ? formatOrg(task.dueDate, 'yyyy-MM-dd') : '');
if (task.dueDate) {
const isAllDay = /T00:00:00(\.000)?Z$/.test(task.dueDate);
setDraftDueTime(isAllDay ? '' : formatOrg(task.dueDate, 'HH:mm'));
} else {
setDraftDueTime('');
}
setDraftTitle(task.title);
setFieldErrors({});
setIsTaskEditMode(true);
}, [fields, fieldValues, task, isTaskFinal]);
const exitTaskEditMode = useCallback(() => {
setIsTaskEditMode(false);
setDraftFieldValues({});
setDraftDueDate('');
setDraftDueTime('');
setDraftTitle('');
setFieldErrors({});
}, []);
const handleDraftFieldChange = useCallback((fieldId: number, value: string | number | boolean | string[] | number[] | null | undefined) => {
setDraftFieldValues(prev => ({ ...prev, [fieldId]: value }));
setFieldErrors(prev => { const next = { ...prev }; delete next[fieldId]; return next; });
}, []);
const handleSaveTask = useCallback(async () => {
if (!task || !fields.length) return;
setIsSavingBatch(true);
try {
// Собираем симулированные значения всех полей для валидации
const simulatedFieldValues = fields.map(f => {
const hasDraft = Object.prototype.hasOwnProperty.call(draftFieldValues, f.id);
const original = fieldValues.find((v: TaskFieldValueData) => v.fieldId === f.id)?.value;
return { fieldId: f.id, value: hasDraft ? draftFieldValues[f.id] : original };
});
// Проверяем обязательные поля
const requiredErrors = validateRequiredFieldsDetailed(
task,
fields as ClientFormField[],
simulatedFieldValues,
users as any,
reminderOrgRolesData?.roles || [],
false,
conditionContextStatuses,
true
);
const nextErrors: Record<number, string> = {};
for (const err of requiredErrors) nextErrors[err.fieldId] = err.message;
// Проверяем неизменяемость изменённых полей
const changedFieldIds: number[] = [];
for (const f of fields) {
const hasDraft = Object.prototype.hasOwnProperty.call(draftFieldValues, f.id);
if (!hasDraft) continue;
const draftVal = draftFieldValues[f.id];
const originalVal = fieldValues.find((v: TaskFieldValueData) => v.fieldId === f.id)?.value;
if (!valuesEqual(draftVal, originalVal)) {
changedFieldIds.push(f.id);
const state = computeFieldState(f as ClientFormField, task, simulatedFieldValues, allConditionFields as ClientFormField[], conditionContext);
if (state.immutable) {
nextErrors[f.id] = `Поле «${f.name}» не может быть изменено в текущем статусе`;
}
}
}
// Название задачи обязательно
if (!draftTitle.trim()) {
toast({ title: 'Ошибка', description: 'Название задачи обязательно', variant: 'destructive' });
setIsSavingBatch(false);
return;
}
if (Object.keys(nextErrors).length > 0) {
setFieldErrors(nextErrors);
toast({ title: 'Ошибка сохранения', description: 'Проверьте обязательные и недоступные для изменения поля', variant: 'destructive' });
setIsSavingBatch(false);
return;
}
setFieldErrors({});
const fieldValuesPayload = changedFieldIds.map(id => ({ fieldId: id, value: draftFieldValues[id] }));
const promises: Promise<unknown>[] = [];
if (fieldValuesPayload.length > 0) {
promises.push(apiRequest('POST', `/api/tasks/${taskIdNumber}/field-values`, { fieldValues: fieldValuesPayload }));
}
const originalDueDate = task.dueDate ? formatOrg(task.dueDate, 'yyyy-MM-dd') : '';
const originalDueTime = task.dueDate && !/T00:00:00(\.000)?Z$/.test(task.dueDate)
? formatOrg(task.dueDate, 'HH:mm')
: '';
if (draftDueDate !== originalDueDate || draftDueTime !== originalDueTime) {
let parsedDueDate: string | null = null;
if (draftDueDate) {
if (draftDueTime) {
parsedDueDate = parseOrgDateTime(draftDueDate, draftDueTime).toISOString();
} else {
parsedDueDate = draftDueDate; // date-only → all-day
}
}
promises.push(taskUpdateRequest(taskIdNumber, { dueDate: parsedDueDate }, task.updatedAt, { dueDate: task.dueDate ?? null }));
}
if (draftTitle.trim() !== task.title) {
promises.push(taskUpdateRequest(taskIdNumber, { title: draftTitle.trim() }, task.updatedAt, { title: task.title }));
}
if (promises.length === 0) {
exitTaskEditMode();
setIsSavingBatch(false);
return;
}
await Promise.all(promises);
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskIdNumber, 'detail'] });
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskIdNumber, 'audit-log'] });
if (formId) queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'tasks', 'with-fields'] });
toast({ title: 'Сохранено' });
exitTaskEditMode();
} catch (err: unknown) {
if (err instanceof OfflineQueuedError) {
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskIdNumber, 'detail'] });
toast({ title: 'Изменения поставлены в очередь' });
exitTaskEditMode();
return;
}
const msg = err instanceof Error ? err.message : 'Неизвестная ошибка';
toast({ title: 'Ошибка сохранения', description: msg, variant: 'destructive' });
} finally {
setIsSavingBatch(false);
}
}, [task, fields, fieldValues, draftFieldValues, draftDueDate, draftDueTime, draftTitle, taskIdNumber, formId, users, reminderOrgRolesData, conditionContextStatuses, allConditionFields, conditionContext, exitTaskEditMode, toast]);
if (!formId || isNaN(formId) || !taskIdNumber || isNaN(taskIdNumber)) {
return (
<Layout>
<div className="p-3 text-center">
<h1 className="text-sm font-medium text-destructive">Ошибка</h1>
<p className="text-muted-foreground text-xs mt-1">Неверный ID формы или задачи</p>
<Link href="/forms">
<Button variant="outline" className="mt-3 h-7 px-2.5 text-xs">
<ArrowLeft className="w-3.5 h-3.5 mr-1.5" />
Вернуться к формам
</Button>
</Link>
</div>
</Layout>
);
}
if (isLoading && !effectiveDetailData) {
return (
<Layout>
<div className="p-6 text-center">
<div className="text-muted-foreground">Загрузка данных...</div>
</div>
</Layout>
);
}
// Извлекаем данные из агрегированного ответа
const form: FormData | undefined = effectiveDetailData?.form;
const formTabs: FormTab[] = effectiveDetailData?.tabs || [];
// Функция для получения имени пользователя по ID
const getUserName = (userId: number): string => {
const user = users.find(u => u.id === userId);
if (user) {
return user.fullName || `${user.firstName || ''} ${user.lastName || ''}`.trim() || user.email;
}
return `ID ${userId}`;
};
const chatEnabled = form?.chatEnabled !== false; // по умолчанию включен
// Десктоп: чат всегда слева от полей; мобильный: чат всегда отдельная вкладка
const chatLayout = isMobile ? 'tab' : 'left';
const hasTabs = formTabs.length > 0;
const systemFieldsTabId = form?.systemFieldsTabId;
// Порядок системных вкладок из настроек формы
const systemTabOrder = form?.systemTabOrder?.length ? form.systemTabOrder.filter((t: string) => t !== 'access') : ['subtasks', 'related', 'chat', 'history'];
// Определяем, показывать ли системные поля на текущей вкладке
const showSystemFields = () => {
if (!hasTabs) {
return true; // Показываем всегда если нет вкладок
}
if (selectedTabId === 'chat' || selectedTabId === 'access') {
return false; // Не показываем на вкладке чата и доступов
}
// Если systemFieldsTabId не задан, показываем на всех вкладках
if (!systemFieldsTabId) {
return true;
}
// Показываем только на назначенной вкладке
return parseInt(selectedTabId) === systemFieldsTabId;
};
const getFilteredFields = () => {
// Поля типа company (DaData) не показываем отдельной строкой —
// их данные уже отображаются через autofill-поля (ИНН, КПП и т.д.)
const visibleFields = fields.filter(f => f.type !== 'company');
if (!hasTabs) {
return visibleFields;
}
if (selectedTabId === 'chat' || selectedTabId === 'access') {
return [];
}
const tabId = parseInt(selectedTabId);
return visibleFields.filter(f => f.tabId === tabId);
};
const filteredFields = getFilteredFields();
const shouldShowSystemFields = showSystemFields();
const getCurrentTab = (): FormTab | undefined => {
if (!hasTabs || selectedTabId === 'chat' || selectedTabId === 'subtasks' || selectedTabId === 'related' || selectedTabId === 'history' || selectedTabId === 'access') {
return undefined;
}
const tabId = parseInt(selectedTabId);
return formTabs.find(t => t.id === tabId);
};
const currentTab = getCurrentTab();
const isTableTab = currentTab?.type === 'table';
// Проверяем, является ли вкладка кастомным модулем
const customModule = currentTab
? modulesData?.modules?.find(m => m.type === currentTab.type && !m.isBuiltIn)
: undefined;
const isCustomModuleTab = !!customModule && !!customModule.config;
if (!form) {
return (
<Layout>
<div className="p-3 text-center">
<h1 className="text-sm font-medium text-destructive">Форма не найдена</h1>
<p className="text-muted-foreground text-xs mt-1">Запрашиваемая форма не существует</p>
<Link href="/forms">
<Button variant="outline" className="mt-3 h-7 px-2.5 text-xs">
<ArrowLeft className="w-3.5 h-3.5 mr-1.5" />
Вернуться к формам
</Button>
</Link>
</div>
</Layout>
);
}
if (!task) {
return (
<Layout>
<div className="p-3 text-center">
<h1 className="text-sm font-medium text-destructive">Задача не найдена</h1>
<p className="text-muted-foreground text-xs mt-1">Запрашиваемая задача не существует</p>
<Link href={`/forms/${formId}/tasks`}>
<Button variant="outline" className="mt-3 h-7 px-2.5 text-xs">
<ArrowLeft className="w-3.5 h-3.5 mr-1.5" />
Вернуться к задачам
</Button>
</Link>
</div>
</Layout>
);
}
// Маппинг fieldCode → raw value
const rawFieldValuesMap: Record<string, unknown> = {};
for (const field of fields) {
const fv = fieldValues.find((v: TaskFieldValueData) => v.fieldId === field.id);
rawFieldValuesMap[field.code] = fv?.value ?? null;
}
// Resolve formula fields in topological order so formula fields can reference each other
const fieldValuesMap: Record<string, unknown> = evaluateAllFormulas(
fields.map(f => ({ code: f.code, type: f.type, options: f.options as Record<string, unknown> | undefined })),
rawFieldValuesMap,
);
// Кастомный JS-вид задачи
const hasCustomLayout = !!(form.detailLayoutCode?.trim());
const customLayoutExtraCtx = hasCustomLayout ? {
fields,
statuses: (effectiveDetailData?.statuses || []) as FormStatus[],
users,
tabs: formTabs,
form: { id: form.id, name: form.name },
} : undefined;
const taskFieldsTabId = !hasTabs || selectedTabId === 'chat' || selectedTabId === 'subtasks' || selectedTabId === 'related' || selectedTabId === 'history' || selectedTabId === 'access'
? undefined
: parseInt(selectedTabId);
// Компонент подзадач
const subtasksTree: Array<TaskData & { formId?: number }> = effectiveDetailData?.subtasks || [];
// Исключаем текущую задачу из списка
const subtasks = subtasksTree.filter((s: TaskData) => s.id !== taskIdNumber);
const hasSubtasks = subtasks.length > 0;
// Проверяем есть ли поля типа "task" для вкладки связанных задач
const hasTaskFields = fields.some(f => f.type === 'task');
const hasRelations = effectiveDetailData?.hasRelations ?? false;
const orderedSystemTabs = systemTabOrder.filter(st => {
if (st === 'chat') return chatLayout === 'tab' && chatEnabled;
if (st === 'subtasks') return hasSubtasks;
if (st === 'related') return hasTaskFields || hasRelations;
return true; // history, access всегда показываем
});
// Создаём маппинг названий форм
const formNamesMap: Record<number, string> = {};
formsListData?.forms?.forEach(f => {
formNamesMap[f.id] = f.name;
});
// Компонент чата (без заголовка — он вынесен в полосу вкладок)
const ChatSection = ({ className = '' }: { className?: string }) => (
<div className={`flex flex-col bg-background ${className}`}>
<div className="flex-1 overflow-hidden">
<TaskChat taskId={taskIdNumber} />
</div>
</div>
);
// Кнопка вкладки
const TabBtn = ({ value, icon: Icon, children, count }: {
value: string;
icon?: React.ElementType;
children: React.ReactNode;
count?: number;
}) => (
<button
onClick={() => setSelectedTabId(value)}
data-testid={`tab-btn-${value}`}
className={`whitespace-nowrap text-xs px-3 py-2 border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
selectedTabId === value
? 'border-primary text-foreground font-medium'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30'
}`}
>
{Icon && <Icon className="w-3 h-3" />}
<span className={isTaskFinal ? 'line-through' : ''}>{children}</span>
{count !== undefined && (
<span className="text-[10px] bg-muted rounded-full px-1 leading-4">{count}</span>
)}
</button>
);
const taskContent = (
<TaskContent
selectedTabId={selectedTabId}
taskIdNumber={taskIdNumber}
formId={formId}
form={form}
task={task}
formTabs={formTabs}
fields={fields}
fieldValues={fieldValues}
users={users}
roles={reminderOrgRolesData?.roles || []}
statuses={effectiveDetailData?.statuses || []}
fieldValuesMap={fieldValuesMap}
conditionContextStatuses={conditionContextStatuses}
isTaskFinal={isTaskFinal}
isFieldVisibleForTask={isFieldVisibleForTask}
isFieldImmutableForTask={isFieldImmutableForTask}
fieldErrors={fieldErrors}
setFieldErrors={setFieldErrors}
modulesData={modulesData}
subtasks={subtasks}
formNamesMap={formNamesMap}
getUserName={getUserName}
systemFieldsTabId={systemFieldsTabId}
hasTabs={hasTabs}
isTaskEditMode={isTaskEditMode}
draftFieldValues={draftFieldValues}
onDraftFieldChange={handleDraftFieldChange}
onRequestGlobalEditMode={enterTaskEditMode}
draftDueDate={draftDueDate}
onDraftDueDateChange={setDraftDueDate}
draftDueTime={draftDueTime}
onDraftDueTimeChange={setDraftDueTime}
/>
);
const editModeBar = isTaskEditMode && (
<div className="shrink-0 border-b bg-muted/50 px-3 py-2 flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">Режим редактирования</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 px-2.5 text-xs"
onClick={exitTaskEditMode}
disabled={isSavingBatch}
>
Отмена
</Button>
<Button
size="sm"
className="h-7 px-2.5 text-xs bg-green-600 hover:bg-green-700 text-white"
onClick={handleSaveTask}
disabled={isSavingBatch}
>
{isSavingBatch ? 'Сохранение...' : 'Сохранить'}
</Button>
</div>
</div>
);
// Мобильный accordion — все разделы задачи в виде сворачиваемых панелей
// Важно: не объявляем как компонент внутри TaskDetail, чтобы избежать remount полей.
const getTabMeta = (tab: FormTab) => {
const isTable = tab.type === 'table';
const customModule = modulesData?.modules?.find(m => m.type === tab.type && !m.isBuiltIn);
const isCustomModule = !!customModule && !!customModule.config;
return { isTable, isCustomModule, customModule };
};
const mobileTaskAccordion = hasCustomLayout ? (
<JsComponentRenderer
code={form.detailLayoutCode!}
task={task as unknown as Record<string, unknown>}
fieldValues={fieldValuesMap}
extraCtx={customLayoutExtraCtx}
/>
) : (
<Accordion type="single" collapsible value={openAccordionValue} onValueChange={setOpenAccordionValue}>
{/* Пользовательские вкладки формы */}
{formTabs.map(tab => {
const { isTable, isCustomModule, customModule } = getTabMeta(tab);
return (
<AccordionItem key={tab.id} value={`tab-${tab.id}`} className="rounded-lg border bg-muted/20 mb-1.5 overflow-hidden">
<AccordionTrigger className="py-2 px-3 text-sm font-medium hover:no-underline">
{tab.name}
</AccordionTrigger>
<AccordionContent className="px-3 pb-3">
{(!systemFieldsTabId || systemFieldsTabId === tab.id) && (
<InlineSystemFields
task={task}
taskId={taskIdNumber}
formId={formId}
getUserName={getUserName}
readOnly={isTaskFinal}
hiddenSystemFields={form.hiddenSystemFields}
users={users}
isTaskFinal={isTaskFinal}
isTaskEditMode={isTaskEditMode}
draftDueDate={draftDueDate}
onDraftDueDateChange={setDraftDueDate}
draftDueTime={draftDueTime}
onDraftDueTimeChange={setDraftDueTime}
onRequestGlobalEditMode={enterTaskEditMode}
/>
)}
{isTable ? (
<TaskRegularTable
taskId={taskIdNumber}
tabId={tab.id}
columns={(tab.tableColumns as TableColumnDef[]) || []}
readOnly={isTaskFinal}
/>
) : isCustomModule && customModule ? (
<DeclarativeTabRenderer
config={customModule.config}
inputSchema={customModule.inputSchema as any}
taskId={taskIdNumber}
tabId={tab.id}
/>
) : (
<TabFieldsContent
tabId={tab.id}
hasTabs={hasTabs}
fields={fields}
fieldValues={fieldValues}
taskIdNumber={taskIdNumber}
formId={formId}
task={task}
users={users}
roles={reminderOrgRolesData?.roles || []}
fieldValuesMap={fieldValuesMap}
conditionContextStatuses={conditionContextStatuses}
isTaskFinal={isTaskFinal}
isFieldVisibleForTask={isFieldVisibleForTask}
isFieldImmutableForTask={isFieldImmutableForTask}
fieldErrors={fieldErrors}
setFieldErrors={setFieldErrors}
isTaskEditMode={isTaskEditMode}
draftFieldValues={draftFieldValues}
onDraftChange={handleDraftFieldChange}
onRequestGlobalEditMode={enterTaskEditMode}
/>
)}
</AccordionContent>
</AccordionItem>
);
})}
{orderedSystemTabs.filter(st => st !== 'chat').map(st => {
if (st === 'subtasks') {
return (
<AccordionItem key={st} value={st} className="rounded-lg border bg-muted/20 mb-1.5 overflow-hidden">
<AccordionTrigger className="py-2 px-3 text-sm font-medium hover:no-underline">
Подзадачи ({subtasks.length})
</AccordionTrigger>
<AccordionContent className="px-3 pb-3">
<SubtasksTabContent
subtasks={subtasks}
taskIdNumber={taskIdNumber}
formId={formId}
formName={form?.name}
formNamesMap={formNamesMap}
/>
</AccordionContent>
</AccordionItem>
);
}
if (st === 'related') {
return (
<AccordionItem key={st} value={st} className="rounded-lg border bg-muted/20 mb-1.5 overflow-hidden">
<AccordionTrigger className="py-2 px-3 text-sm font-medium hover:no-underline">
Связанные
</AccordionTrigger>
<AccordionContent className="px-3 pb-3">
<RelatedTasksTab taskId={taskIdNumber} />
</AccordionContent>
</AccordionItem>
);
}
if (st === 'history') {
return (
<AccordionItem key={st} value={st} className="rounded-lg border bg-muted/20 mb-1.5 overflow-hidden">
<AccordionTrigger className="py-2 px-3 text-sm font-medium hover:no-underline">
История
</AccordionTrigger>
<AccordionContent className="px-3 pb-3">
<TaskHistory taskId={taskIdNumber} />
</AccordionContent>
</AccordionItem>
);
}
// access tab removed from task detail view (moved to form editor)
if (st === 'access') {
return null;
}
return null;
})}
</Accordion>
);
// Кнопки согласования: переходы доступные текущему пользователю
const allTransitions: FormTransition[] = effectiveDetailData?.transitions || [];
const approvalTransitions = allTransitions.filter(
(t) => t.fromStatusId === hookTaskCurrentStatusId && (
t.assigneeUserId === currentUser?.id ||
(t.resolvedAssigneeUserIds ?? []).includes(currentUser?.id as number)
)
);
// Компонент статуса задачи
const TaskStatusBadge = () => {
if (!currentStatus) return null;
return (
<div
className="flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium"
style={{
backgroundColor: currentStatus.color + '15',
color: currentStatus.color,
border: `1px solid ${currentStatus.color}30`
}}
data-testid="badge-task-status"
>
{currentStatus.isFinal && <Lock className="w-2.5 h-2.5 shrink-0" />}
{isTransitionPending && <span title="Переход ожидает синхронизации"><Clock className="w-2.5 h-2.5 shrink-0 animate-pulse" /></span>}
{currentStatus.name}
</div>
);
};
return (
<Sidebar>
<div className="flex flex-col h-full overflow-hidden">
{/* Offline cache banner */}
{isUsingOfflineCache && cacheTimestamp && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-amber-50 dark:bg-amber-950/30 border-b border-amber-200 dark:border-amber-800 shrink-0">
<span className="text-amber-600 dark:text-amber-400 shrink-0">⚡</span>
<span className="text-xs text-amber-700 dark:text-amber-300">
Офлайн. Кеш от {new Date(cacheTimestamp).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}.
{taskPendingCount > 0
? ` ${taskPendingCount} изм. ожидают синхронизации.`
: ' Изменения сохранятся при восстановлении связи.'}
</span>
</div>
)}
{/* Pending operations banner (online but unsent) */}
{!isUsingOfflineCache && taskPendingCount > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-950/30 border-b border-blue-200 dark:border-blue-800 shrink-0">
<span className="text-blue-600 dark:text-blue-400 shrink-0">↻</span>
<span className="text-xs text-blue-700 dark:text-blue-300">
{taskPendingCount} изм. ожидают синхронизации с сервером...
</span>
</div>
)}
{/* Компактная шапка */}
<div className="border-b bg-background px-3 py-2">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
<button
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
data-testid="button-back-to-tasks"
onClick={() => {
if (window.history.length > 1) {
window.history.back();
} else {
setLocation(`/forms/${formId}/tasks`);
}
}}
>
<ArrowLeft className="w-4 h-4" />
</button>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs font-mono text-muted-foreground" data-testid="text-task-id-header">
#{task.id}
</span>
<InlineField
readView={
<div
className="group flex items-center gap-1 min-w-0"
onDoubleClick={!isTaskFinal ? enterTaskEditMode : undefined}
>
<h1 className={`text-sm font-medium break-words md:truncate ${isTaskFinal ? 'line-through text-muted-foreground' : 'text-foreground'}`} data-testid="text-task-title">
{task.title}
</h1>
</div>
}
editor={
<input
autoFocus
className="text-sm font-medium border border-input rounded px-1.5 py-0.5 bg-background focus:outline-none focus:ring-1 focus:ring-ring min-w-0 w-full"
value={draftTitle}
onChange={e => setDraftTitle(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveTask();
}
if (e.key === 'Escape') exitTaskEditMode();
}}
data-testid="input-task-title"
/>
}
isEditing={isTaskEditMode && !isTaskFinal}
editable={!isTaskFinal}
onEnterEdit={enterTaskEditMode}
fitOverlay={false}
overlayMinWidth={200}
overlayClassName="overflow-visible"
/>
</div>
</div>
</div>
{/* Статус и действия в шапке */}
<div className="flex items-center gap-2 flex-wrap md:justify-end shrink-0">
{/* Кнопки согласования */}
{approvalTransitions.map((transition) => {
const transitionDisabled = executeTransitionMutation.isPending || isTransitionPending;
if (transition.actionType === 'custom') {
return (
<Button
key={transition.id}
size="sm"
variant="secondary"
data-testid={`button-custom-${transition.id}`}
disabled={transitionDisabled}
onClick={() => executeTransitionMutation.mutate({ transitionId: transition.id })}
>
{transition.actionName || 'Выполнить'}
</Button>
);
}
if (transition.actionType === 'reject' && !transition.rejectToStatusId) {
return (
<Button
key={transition.id}
size="sm"
style={{ backgroundColor: transition.rejectButtonColor || '#ef4444' }}
className="text-white border-0"
data-testid={`button-reject-${transition.id}`}
disabled={transitionDisabled}
onClick={() => executeTransitionMutation.mutate({ transitionId: transition.id })}
>
<X className="w-3.5 h-3.5 mr-1" />
{transition.rejectButtonText || 'Отклонить'}
</Button>
);
}
return (
<div key={transition.id} className="flex gap-1.5">
<Button
size="sm"
style={{ backgroundColor: transition.approveButtonColor || '#22c55e' }}
className="text-white border-0"
data-testid={`button-approve-${transition.id}`}
disabled={transitionDisabled}
onClick={() => executeTransitionMutation.mutate({ transitionId: transition.id, action: 'approve' })}
>
<Check className="w-3.5 h-3.5 mr-1" />
{transition.approveButtonText || 'Одобрить'}
</Button>
{transition.rejectToStatusId && (
<Button
size="sm"
style={{ backgroundColor: transition.rejectButtonColor || '#ef4444' }}
className="text-white border-0"
data-testid={`button-reject-${transition.id}`}
disabled={transitionDisabled}
onClick={() => executeTransitionMutation.mutate({ transitionId: transition.id, action: 'reject' })}
>
<X className="w-3.5 h-3.5 mr-1" />
{transition.rejectButtonText || 'Отклонить'}
</Button>
)}
</div>
);
})}
{isAdmin(currentUser) && (
<button
onClick={() => setShowDeleteDialog(true)}
className="flex items-center justify-center w-7 h-7 rounded text-destructive hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Удалить задачу"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => setShowReminderDialog(true)}
className="flex items-center justify-center w-7 h-7 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Напоминания"
>
<Bell className="w-3.5 h-3.5" />
</button>
<button
onClick={() => toggleHideMutation.mutate()}
disabled={toggleHideMutation.isPending}
className="flex items-center justify-center w-7 h-7 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50"
title={hideStatus?.isHidden ? 'Вернуть во входящие' : 'Скрыть из входящих'}
>
{hideStatus?.isHidden ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
</button>
<TaskQrButton formId={formId} taskId={taskIdNumber} title={task.title} />
<TaskStatusBadge />
</div>
</div>
</div>
{/* Горизонтальная полоса вкладок — десктоп */}
{!isMobile && (
<div className="border-b bg-background flex items-stretch overflow-hidden shrink-0">
{/* Чат слева — метка в левой секции таббара (десктоп) */}
{chatLayout === 'left' && chatEnabled && expandedPane !== 'task' && (
<div className="w-1/3 border-r px-3 flex items-center justify-between shrink-0">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Чат</span>
<button
onClick={() => toggleExpand('chat')}
className="p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={expandedPane === 'chat' ? 'Свернуть чат' : 'Развернуть чат'}
>
{expandedPane === 'chat' ? <Minimize2 className="w-3 h-3" /> : <Maximize2 className="w-3 h-3" />}
</button>
</div>
)}
{/* Кнопка разворота задачи — перед вкладками (только на десктопе с чатом) */}
{chatLayout === 'left' && chatEnabled && expandedPane !== 'chat' && (
<button
onClick={() => toggleExpand('task')}
className="px-2 flex items-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0"
title={expandedPane === 'task' ? 'Свернуть задачу' : 'Развернуть задачу'}
>
{expandedPane === 'task' ? <Minimize2 className="w-3 h-3" /> : <Maximize2 className="w-3 h-3" />}
</button>
)}
{/* Кнопки вкладок */}
{expandedPane !== 'chat' && (
<div className="relative flex-1 overflow-hidden">
{showLeftArrow && (
<button
className="absolute left-0 top-0 z-10 w-6 h-full bg-background flex items-center justify-center border-r shadow-[2px_0_4px_rgba(0,0,0,0.08)] text-muted-foreground hover:text-foreground transition-colors"
onClick={() => tabsScrollEl?.scrollBy({ left: -160, behavior: 'smooth' })}
>
<ChevronLeft className="w-3 h-3" />
</button>
)}
<div
ref={tabsScrollRef}
className="overflow-x-auto [&::-webkit-scrollbar]:hidden h-full"
style={{ scrollbarWidth: 'none' }}
>
<div className="flex items-stretch h-full">
{formTabs.map(tab => (
<TabBtn key={tab.id} value={tab.id.toString()}>{tab.name}</TabBtn>
))}
{orderedSystemTabs.map(st => (
<TabBtn
key={st}
value={st}
icon={SYSTEM_TAB_ICONS[st]}
count={st === 'subtasks' ? subtasks.length : undefined}
>
{SYSTEM_TAB_LABELS[st]}
</TabBtn>
))}
</div>
</div>
{showRightArrow && (
<button
className="absolute right-0 top-0 z-10 w-6 h-full bg-background flex items-center justify-center border-l shadow-[-2px_0_4px_rgba(0,0,0,0.08)] text-muted-foreground hover:text-foreground transition-colors"
onClick={() => tabsScrollEl?.scrollBy({ left: 160, behavior: 'smooth' })}
>
<ChevronRight className="w-3 h-3" />
</button>
)}
</div>
)}
</div>
)}
{editModeBar}
{/* Мобильный таб-бар — только Задача / Чат */}
{isMobile && chatEnabled && (
<div className="border-b bg-background flex items-stretch overflow-hidden shrink-0">
<button
onClick={() => setSelectedTabId('task')}
className={`flex-1 text-xs px-3 py-2 border-b-2 flex items-center justify-center gap-1.5 transition-colors ${
selectedTabId !== 'chat'
? 'border-primary text-foreground font-medium'
: 'border-transparent text-muted-foreground'
}`}
>
<FileText className="w-3.5 h-3.5" />
Задача
</button>
<button
onClick={() => setSelectedTabId('chat')}
className={`flex-1 text-xs px-3 py-2 border-b-2 flex items-center justify-center gap-1.5 transition-colors ${
selectedTabId === 'chat'
? 'border-primary text-foreground font-medium'
: 'border-transparent text-muted-foreground'
}`}
>
<MessageSquare className="w-3.5 h-3.5" />
Чат
</button>
</div>
)}
{/* Основной контент */}
<div className="flex-1 flex overflow-hidden">
{/* Лейаут "left" — чат слева (десктоп) */}
{chatLayout === 'left' && (
<>
{chatEnabled && expandedPane !== 'task' && (
<ChatSection className={expandedPane === 'chat' ? "flex-1" : "w-1/3 border-r"} />
)}
{expandedPane !== 'chat' && (
<div className={(!chatEnabled || expandedPane === 'task') ? "flex-1 pl-2 pr-4 py-4 overflow-auto" : "w-2/3 pl-2 pr-4 py-4 overflow-auto"}>
{taskContent}
</div>
)}
</>
)}
{/* Лейаут "tab" — чат как отдельная вкладка (мобильный) */}
{chatLayout === 'tab' && (
<>
{selectedTabId === 'chat' && chatEnabled ? (
<ChatSection className="flex-1" />
) : (
<div className="flex-1 pl-2 pr-4 py-4 overflow-auto">
{isMobile ? mobileTaskAccordion : taskContent}
</div>
)}
</>
)}
</div>
</div>
{/* Reminder Dialog */}
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-sm font-medium">Напоминания по задаче</DialogTitle>
<DialogDescription className="sr-only">Создание и управление напоминаниями для этой задачи</DialogDescription>
</DialogHeader>
{/* Existing reminders list */}
{reminders.length > 0 && (
<div className="space-y-1.5 mb-4">
<p className="text-xs text-muted-foreground font-medium">Активные напоминания</p>
{reminders.map((r) => (
<div key={r.id} className="flex items-start justify-between gap-2 p-2 rounded border bg-muted/40 text-xs">
<div className="flex-1 min-w-0">
<div className="font-medium">
{new Date(r.remindAt).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
</div>
{r.note && <div className="text-muted-foreground truncate">{r.note}</div>}
<div className="flex flex-wrap gap-1 mt-1">
{r.recipients.map((rec, i) => (
<Badge key={i} variant="secondary" className="text-[10px] px-1.5 py-0">
{rec.type === 'user' ? (users.find((u: any) => u.id === rec.userId)?.fullName || `#${rec.userId}`) : rec.type === 'orgRole' ? `Роль org: ${rec.roleId}` : `Роль: ${(rec as any).role}`}
</Badge>
))}
</div>
</div>
<button
onClick={() => deleteReminder(r.id)}
disabled={deletingReminderId === r.id}
className="shrink-0 text-muted-foreground hover:text-destructive disabled:opacity-40"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
{/* New reminder form */}
<div className="space-y-3">
<p className="text-xs text-muted-foreground font-medium">Новое напоминание</p>
<div>
<label className="text-xs text-muted-foreground block mb-1">Дата и время</label>
<input
type="datetime-local"
value={reminderDate}
onChange={(e) => {
const newVal = e.target.value;
const prev = prevReminderDateRef.current;
prevReminderDateRef.current = newVal;
setReminderDate(newVal);
// Закрываем пикер только когда изменились минуты (последний шаг выбора)
// Формат: YYYY-MM-DDTHH:MM — часть [0..10] = дата, [11..12] = часы, [14..15] = минуты
if (prev && newVal && newVal.length >= 16 && prev.length >= 16) {
const sameDate = prev.substring(0, 11) === newVal.substring(0, 11);
const sameHour = prev.substring(11, 13) === newVal.substring(11, 13);
const diffMin = prev.substring(14, 16) !== newVal.substring(14, 16);
if (sameDate && sameHour && diffMin) {
setTimeout(() => e.target.blur(), 0);
}
}
}}
className="w-full h-7 px-2 text-xs rounded border bg-background"
/>
</div>
<div>
<label className="text-xs text-muted-foreground block mb-1">Заметка (необязательно)</label>
<Textarea
value={reminderNote}
onChange={(e) => setReminderNote(e.target.value)}
placeholder="Текст напоминания..."
className="text-xs min-h-[60px]"
/>
</div>
<div>
<label className="text-xs text-muted-foreground block mb-1">Получатели</label>
<div ref={recipientContainerRef} className="relative">
{/* Selected chips */}
{selectedRecipients.length > 0 && (
<div className="flex flex-wrap gap-1 mb-1.5">
{selectedRecipients.map((r) => (
<span
key={r.id}
className="inline-flex items-center gap-1 bg-muted border rounded px-1.5 py-0.5 text-[11px]"
>
{r.type === 'role' ? (
<Users className="w-3 h-3 text-muted-foreground" />
) : (
<span className="w-4 h-4 rounded-full bg-primary/20 text-primary text-[9px] flex items-center justify-center font-medium">
{r.label.charAt(0).toUpperCase()}
</span>
)}
{r.label}
<button
type="button"
onClick={() => setSelectedRecipients(prev => prev.filter(x => x.id !== r.id))}
className="text-muted-foreground hover:text-foreground ml-0.5"
>
<X className="w-2.5 h-2.5" />
</button>
</span>
))}
</div>
)}
{/* Search input */}
<div className="flex items-center gap-1.5 border rounded px-2 h-7 bg-background focus-within:ring-1 focus-within:ring-ring">
<Search className="w-3 h-3 text-muted-foreground shrink-0" />
<input
ref={recipientInputRef}
type="text"
value={recipientSearch}
onChange={(e) => { setRecipientSearch(e.target.value); setRecipientDropdownOpen(true); }}
onFocus={() => setRecipientDropdownOpen(true)}
onBlur={() => setTimeout(() => setRecipientDropdownOpen(false), 150)}
placeholder="Поиск пользователей и ролей..."
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
/>
</div>
{/* Dropdown */}
{recipientDropdownOpen && filteredRecipientOptions.length > 0 && (
<div className="absolute z-50 mt-1 w-full bg-popover border rounded shadow-md max-h-48 overflow-y-auto">
{filteredRecipientOptions.map((opt) => (
<button
key={opt.id}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setSelectedRecipients(prev => [...prev, opt]);
setRecipientSearch('');
recipientInputRef.current?.focus();
}}
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-xs hover:bg-accent text-left"
>
{opt.type === 'role' ? (
<div className="w-5 h-5 rounded-full bg-muted flex items-center justify-center shrink-0">
<Users className="w-3 h-3 text-muted-foreground" />
</div>
) : (
<div className="w-5 h-5 rounded-full bg-primary/20 text-primary text-[10px] flex items-center justify-center font-semibold shrink-0">
{opt.label.charAt(0).toUpperCase()}
</div>
)}
<span>{opt.label}</span>
</button>
))}
</div>
)}
{recipientDropdownOpen && filteredRecipientOptions.length === 0 && recipientSearch.trim() && (
<div className="absolute z-50 mt-1 w-full bg-popover border rounded shadow-md px-2.5 py-2 text-xs text-muted-foreground">
Ничего не найдено
</div>
)}
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setShowReminderDialog(false)}
className="h-7 px-2.5 text-xs"
>
Закрыть
</Button>
<Button
size="sm"
onClick={async () => {
console.log('[reminder] CREATE button clicked', { reminderDate, recipients: selectedRecipients.length, taskIdNumber });
if (!reminderDate) {
toast({ title: 'Укажите дату напоминания', variant: 'destructive' });
return;
}
const remindAtDate = new Date(reminderDate);
if (isNaN(remindAtDate.getTime())) {
toast({ title: 'Укажите корректную дату', variant: 'destructive' });
return;
}
const recipients: any[] = selectedRecipients.map(r => {
if (r.type === 'role') return { type: 'role', role: r.role };
if (r.type === 'orgRole') return { type: 'orgRole', roleId: r.roleId };
return { type: 'user', userId: r.userId };
});
if (recipients.length === 0) {
toast({ title: 'Выберите хотя бы одного получателя', variant: 'destructive' });
return;
}
setIsReminderSubmitting(true);
try {
await createReminder({
remindAt: remindAtDate.toISOString(),
note: reminderNote || undefined,
recipients,
});
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskIdNumber, 'reminders'] });
setReminderDate('');
prevReminderDateRef.current = '';
setReminderNote('');
setSelectedRecipients(currentUser ? [{ id: `user:${currentUser.id}`, label: `Я (${currentUser.firstName} ${currentUser.lastName})`, type: 'user', userId: currentUser.id }] : []);
setRecipientSearch('');
toast({ title: 'Напоминание создано' });
setShowReminderDialog(false);
} catch (err: any) {
const msg = err?.serverData?.error || err?.message || 'Произошла ошибка';
toast({ title: 'Ошибка создания напоминания', description: msg, variant: 'destructive' });
} finally {
setIsReminderSubmitting(false);
}
}}
disabled={isReminderSubmitting}
className="h-7 px-2.5 text-xs"
>
<Plus className="w-3 h-3 mr-1" />
{isReminderSubmitting ? 'Создание...' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Task Confirmation Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Удаление задачи</DialogTitle>
<DialogDescription>
Вы уверены, что хотите удалить задачу <strong>"{task?.title}"</strong>?
<br /><br />
<span className="text-destructive font-medium">
Задача будет удалена безвозвратно вместе со всеми сообщениями, файлами и историей изменений.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
className="h-7 px-2.5 text-xs"
onClick={() => setShowDeleteDialog(false)}
>
Отмена
</Button>
<Button
variant="destructive"
className="h-7 px-2.5 text-xs"
onClick={() => deleteTaskMutation.mutate()}
disabled={deleteTaskMutation.isPending}
>
{deleteTaskMutation.isPending ? 'Удаление...' : 'Удалить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* (debug overlay removed) */}
</Sidebar>
);
};
export default TaskDetail;