- textarea overlay max-height увеличен до min(85vh, 700px) - убрана правая кнопка 'Добавить файл' (дубль левой иконки) - файлы идут горизонтально, длинные имена обрезаются ellipsis, список занимает доступную ширину
4230 lines
183 KiB
TypeScript
4230 lines
183 KiB
TypeScript
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; |