refactor(task-detail): разбиение TaskDetail.tsx (3278→804) — механическая экстракция

Шаг 2.2 (часть 2) плана production-готовности:
- 14 файлов в components/task-detail/ (типы, вкладки, диалоги, шапка, таб-бар,
  мобильный accordion, хук условий полей)
- посимвольная сверка: все несовпадения объяснены (export-префиксы/импорты/пропсы)
- check/lint/build чисто, vitest 104/104
This commit is contained in:
2026-09-08 06:53:25 +03:00
parent 6bbedad291
commit 52b583c671
16 changed files with 2952 additions and 2571 deletions

View File

@@ -0,0 +1,75 @@
import { useMutation } from '@tanstack/react-query';
import { useLocation } from 'wouter';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { apiRequest, queryClient } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
interface DeleteTaskDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
taskIdNumber: number;
taskTitle?: string;
formId?: number;
fallbackFormId: number;
}
export function DeleteTaskDialog({ open: showDeleteDialog, onOpenChange: setShowDeleteDialog, taskIdNumber, taskTitle, formId: detailFormId, fallbackFormId }: DeleteTaskDialogProps) {
const { toast } = useToast();
const [, setLocation] = useLocation();
// Мутация удаления задачи
const deleteTaskMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest('DELETE', `/api/tasks/${taskIdNumber}`);
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
if (detailFormId) {
queryClient.invalidateQueries({ queryKey: ['/api/forms', detailFormId, 'tasks'] });
}
toast({ title: 'Задача удалена' });
setShowDeleteDialog(false);
setLocation(`/forms/${detailFormId || fallbackFormId}/tasks`);
},
onError: (error: Error) => {
toast({ variant: 'destructive', title: 'Не удалось удалить задачу', description: error instanceof Error ? error.message : 'Проверьте соединение и попробуйте ещё раз.' });
},
});
return (
<>
{/* Delete Task Confirmation Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Удалить задачу «{taskTitle}»?</DialogTitle>
<DialogDescription>
<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>
</>
);
}

View File

@@ -0,0 +1,632 @@
import { useState, useEffect, useRef } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { AlertCircle, Pencil, Search, X, Users, User, UserCheck, Clock } from 'lucide-react';
import { apiRequest, queryClient, OfflineQueuedError, taskUpdateRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { cn } from '@/lib/utils';
import { formatOrg, parseOrgDateTime } from '@/lib/timezone';
import { isInteractiveClickTarget, isRadixPortalTarget } from '@/components/task-fields/FieldValueRow';
import type { TaskData } from '@/components/task-fields/FieldValueRow';
import { useAuth } from '@/hooks/useAuth';
import { isAdmin } from '@/lib/permissions';
import { useOfflineSyncContext } from '@/contexts/OfflineSyncContext';
import { saveTask } from '@/lib/tasksCache';
import type { Task } from '@shared/schema';
import type { TaskDetailResponse, TaskRoleData, OrgRole, UserSummary } from './types';
export function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hiddenSystemFields, users, isTaskFinal }: { task: TaskData; taskId: number; formId?: number; getUserName: (id: number) => string; readOnly?: boolean; hiddenSystemFields?: string[] | null; users?: UserSummary[]; isTaskFinal?: boolean }) {
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('');
const assigneeTriggerRef = useRef<HTMLDivElement>(null);
// Per-field редактирование срока: локальный черновик даты/времени
const [isEditingDue, setIsEditingDue] = useState(false);
const [dueDateDraft, setDueDateDraft] = useState('');
const [dueTimeDraft, setDueTimeDraft] = useState('');
const dueDraftRef = useRef({ date: '', time: '' });
const dueEditorRef = useRef<HTMLDivElement>(null);
// Двойное касание на мобильных — вход в редактирование срока
const lastDueTapRef = useRef<number>(0);
const handleDueDoubleTap = (e: React.TouchEvent) => {
if (readOnly) return;
if (isInteractiveClickTarget(e.target)) return;
const now = Date.now();
if (now - lastDueTapRef.current < 300) {
e.stopPropagation();
enterDueEdit();
}
lastDueTapRef.current = now;
};
const enterDueEdit = () => {
if (readOnly || isEditingDue) return;
const date = task.dueDate ? formatOrg(task.dueDate, 'yyyy-MM-dd') : '';
const time = task.dueDate && !/T00:00:00(\.000)?Z$/.test(task.dueDate) ? formatOrg(task.dueDate, 'HH:mm') : '';
dueDraftRef.current = { date, time };
setDueDateDraft(date);
setDueTimeDraft(time);
setIsEditingDue(true);
};
// Актуальная задача для сохранений из эффектов (после optimistic-обновлений проп устаревает)
const taskRef = useRef(task);
taskRef.current = task;
// Сохранение срока через taskUpdateRequest (updatedAt — контроль конфликтов, как раньше в batch-сохранении)
const saveDueDate = async (dateStr: string, timeStr: string, opts?: { close?: boolean }) => {
const close = opts?.close !== false;
const currentTask = taskRef.current;
const originalDate = currentTask.dueDate ? formatOrg(currentTask.dueDate, 'yyyy-MM-dd') : '';
const originalTime = currentTask.dueDate && !/T00:00:00(\.000)?Z$/.test(currentTask.dueDate) ? formatOrg(currentTask.dueDate, 'HH:mm') : '';
if (close) setIsEditingDue(false);
if (dateStr === originalDate && timeStr === originalTime) return;
let parsedDueDate: string | null = null;
if (dateStr) {
parsedDueDate = timeStr ? parseOrgDateTime(dateStr, timeStr).toISOString() : dateStr;
}
// Optimistic-обновление кэша детали задачи
const prev = queryClient.getQueryData<TaskDetailResponse>(['/api/tasks', taskId, 'detail']);
if (prev) {
const merged: TaskDetailResponse = { ...prev, task: { ...prev.task, dueDate: parsedDueDate ?? undefined } };
queryClient.setQueryData(['/api/tasks', taskId, 'detail'], merged);
saveTask(taskId, merged).catch(() => {});
}
try {
await taskUpdateRequest(taskId, { dueDate: parsedDueDate }, currentTask.updatedAt, { dueDate: currentTask.dueDate ?? 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'] });
} catch (err) {
if (err instanceof OfflineQueuedError) return;
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] });
const msg = err instanceof Error ? err.message : 'Не удалось сохранить срок. Проверьте соединение и попробуйте ещё раз.';
toast({ title: 'Ошибка сохранения срока', description: msg, variant: 'destructive' });
}
};
// Клик вне редактора срока — сохранить, Esc — отмена
useEffect(() => {
if (!isEditingDue) return;
const handleOutsideClick = (e: MouseEvent) => {
if (dueEditorRef.current?.contains(e.target as Node)) return;
if (isRadixPortalTarget(e.target)) return;
saveDueDate(dueDraftRef.current.date, dueDraftRef.current.time);
};
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsEditingDue(false);
};
document.addEventListener('click', handleOutsideClick);
document.addEventListener('keydown', handleEsc);
return () => {
document.removeEventListener('click', handleOutsideClick);
document.removeEventListener('keydown', handleEsc);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEditingDue]);
// 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; delegatedFromUserId?: number | null; pendingReview?: boolean; 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 pendingReviewAssignee = assignedTaskUsers.find(a => a.pendingReview && a.userId === currentUser?.id);
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 ackDelegationMutation = useMutation<{ success: boolean }, Error, void>({
mutationFn: async () => {
const res = await apiRequest('POST', `/api/tasks/${taskId}/delegation-ack`);
return res.json();
},
onSuccess: () => {
refetchAssignees();
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'assignees'] });
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] });
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()));
// Клик вне блока системных полей закрывает dropdown ответственных; Escape — закрыть и вернуть фокус на триггер
useEffect(() => {
if (!assigneeDropdownOpen) return;
const handleOutsideClick = (e: MouseEvent) => {
if (wrapperRef.current?.contains(e.target as Node)) return;
setAssigneeDropdownOpen(false);
};
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setAssigneeDropdownOpen(false);
assigneeTriggerRef.current?.focus();
}
};
document.addEventListener('click', handleOutsideClick);
document.addEventListener('keydown', handleEsc);
return () => {
document.removeEventListener('click', handleOutsideClick);
document.removeEventListener('keydown', handleEsc);
};
}, [assigneeDropdownOpen]);
// Левая колонка: карандашик для назначения ответственного
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-8 h-8 md:w-5 md: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">
{/* Баннер ознакомления: задача обновлена заместителем, пока пользователь отсутствовал */}
{pendingReviewAssignee && (
<div className="flex items-center gap-2 flex-wrap rounded border border-warning-foreground/20 bg-warning text-warning-foreground px-2 py-1.5 mb-1 text-xs" data-testid="banner-delegation-review">
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
<span className="flex-1 min-w-0">Задача обновлена вашим заместителем. Ознакомьтесь и подтвердите.</span>
<Button
variant="secondary"
size="sm"
className="h-6 text-[11px] px-2"
disabled={ackDelegationMutation.isPending}
onClick={() => ackDelegationMutation.mutate()}
data-testid="button-delegation-ack"
>
Ознакомлен
</Button>
</div>
)}
{/* Assignee + Roles */}
{!hidden.has('assignedTo') && (
<div className="group/sysrow flex items-center gap-1.5 min-h-[36px] md:min-h-[var(--field-row-min,28px)] py-0.5 -mx-1 px-1 rounded transition-colors hover:bg-muted/50">
{!readOnly
? leftSlot(() => { setAssigneeDropdownOpen(v => !v); setAssigneeSearch(''); })
: emptySlot}
<span className="text-muted-foreground shrink-0 text-[13px] leading-5">Ответственный:</span>
{!readOnly && currentUser && !assignedUserIds.has(currentUser.id) && task.assignedTo !== currentUser.id && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs text-primary hover:text-primary -ml-1"
onClick={() => addAssigneeMutation.mutate(currentUser.id)}
disabled={addAssigneeMutation.isPending}
title="Назначить на себя"
>
<UserCheck className="w-3 h-3 mr-1" />
На себя
</Button>
)}
<div
ref={assigneeTriggerRef}
role={readOnly ? undefined : 'button'}
tabIndex={readOnly ? undefined : 0}
aria-expanded={readOnly ? undefined : assigneeDropdownOpen}
aria-label={readOnly ? undefined : 'Выбрать ответственных'}
className={cn('relative flex-1 min-w-0 rounded focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', !readOnly && 'cursor-pointer')}
onClick={(e) => {
if (readOnly) return;
// Клики по кнопкам снятия ответственных/ролей не открывают dropdown
if (e.target instanceof Element && e.target.closest('button')) return;
setAssigneeDropdownOpen(v => !v);
setAssigneeSearch('');
}}
onKeyDown={(e) => {
if (readOnly) return;
if (e.target instanceof Element && e.target.closest('button') && e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setAssigneeDropdownOpen(v => !v);
setAssigneeSearch('');
}
}}
>
{/* 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)}
{a.delegatedFromUserId != null && (
<Badge
variant="outline"
className="text-[9px] h-3.5 px-1 font-normal"
title={`По замещению за ${getUserName(a.delegatedFromUserId)}`}
>
за {getUserName(a.delegatedFromUserId)}
</Badge>
)}
{!readOnly && (
<button
onClick={() => removeAssigneeMutation.mutate(a.userId)}
disabled={removeAssigneeMutation.isPending}
className="ml-0.5 inline-flex items-center justify-center w-6 h-6 -my-1 rounded relative after:absolute after:-inset-1 after:content-[''] 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 inline-flex items-center justify-center w-6 h-6 -my-1 rounded relative after:absolute after:-inset-1 after:content-[''] 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" role="listbox" aria-label="Пользователи и роли">
<div className="flex items-center gap-1 border rounded px-2 h-7 mb-1 bg-background focus-within:ring-1 focus-within:ring-ring">
<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}`}
role="option"
aria-selected={false}
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-sm 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}`}
role="option"
aria-selected={false}
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-sm 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">
{assigneeSearch.trim() ? `По запросу «${assigneeSearch.trim()}» ничего не найдено` : 'Нет доступных пользователей'}
</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-destructive 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-warning-foreground animate-pulse" />
</span>
)}
</span>
);
const dueDateEditor = (
<div ref={dueEditorRef} className="flex items-center gap-1.5 animate-in fade-in-0 duration-150">
<input
type="date"
className="border rounded px-1.5 h-7 text-base md:text-[13px] leading-6 bg-background focus:outline-none focus:ring-1 focus:ring-ring"
value={dueDateDraft}
aria-label="Дата срока"
onChange={e => {
const v = e.target.value;
dueDraftRef.current.date = v;
setDueDateDraft(v);
// Дата выбрана полностью — сохраняем сразу, редактор остаётся для времени
if (v === '' || /^\d{4}-\d{2}-\d{2}$/.test(v)) {
saveDueDate(v, dueDraftRef.current.time, { close: false });
}
}}
autoFocus
onKeyDown={e => {
if (e.key === 'Enter') { e.preventDefault(); saveDueDate(dueDraftRef.current.date, dueDraftRef.current.time); }
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setIsEditingDue(false); }
}}
/>
<input
type="time"
className="border rounded px-1.5 h-7 text-base md:text-[13px] leading-6 bg-background focus:outline-none focus:ring-1 focus:ring-ring w-[90px]"
value={dueTimeDraft}
aria-label="Время срока"
onChange={e => {
dueDraftRef.current.time = e.target.value;
setDueTimeDraft(e.target.value);
}}
onBlur={() => saveDueDate(dueDraftRef.current.date, dueDraftRef.current.time)}
onKeyDown={e => {
if (e.key === 'Enter') { e.preventDefault(); saveDueDate(dueDraftRef.current.date, dueDraftRef.current.time); }
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setIsEditingDue(false); }
}}
/>
{dueDateDraft && (
<button
type="button"
title="Очистить срок"
className="inline-flex items-center justify-center w-6 h-6 rounded relative after:absolute after:-inset-2 after:content-[''] text-muted-foreground hover:text-destructive hover:bg-muted/50 transition-[color,background-color,transform] active:scale-[0.96]"
onClick={() => saveDueDate('', '')}
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
);
return (
<div
className="group/sysrow flex items-center gap-1.5 min-h-[36px] md:min-h-[var(--field-row-min,28px)] py-0.5 -mx-1 px-1 rounded transition-colors hover:bg-muted/50"
>
{emptySlot}
<span className="text-muted-foreground shrink-0 text-[13px] leading-5">Срок:</span>
{isEditingDue ? (
dueDateEditor
) : (
<div
className={cn('text-[13px] leading-5', !readOnly && 'cursor-text')}
title={!readOnly ? 'Двойной клик для редактирования' : undefined}
onDoubleClick={(e) => {
if (readOnly) return;
if (isInteractiveClickTarget(e.target)) return;
enterDueEdit();
}}
onTouchEnd={handleDueDoubleTap}
>
{dueDateReadView}
</div>
)}
</div>
);
})()}
{/* Created (read-only) */}
{!hidden.has('createdAt') && (
<div className="flex items-center gap-1.5 min-h-[36px] md:min-h-[var(--field-row-min,28px)] py-0.5 -mx-1 px-1 rounded">
{emptySlot}
<span className="text-muted-foreground shrink-0 text-[13px] leading-5">Создана:</span>
<span data-testid="text-task-created" className="text-[13px] leading-5">
{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.5 min-h-[36px] md:min-h-[var(--field-row-min,28px)] py-0.5 -mx-1 px-1 rounded">
{emptySlot}
<span className="text-muted-foreground shrink-0 text-[13px] leading-5">Завершена:</span>
<span data-testid="text-task-completed" className="text-[13px] leading-5">
{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.5 min-h-[36px] md:min-h-[var(--field-row-min,28px)] py-0.5 -mx-1 px-1 rounded">
{emptySlot}
<span className="text-muted-foreground shrink-0 text-[13px] leading-5">Автор:</span>
<span data-testid="text-task-creator" className="text-[13px] leading-5">{getUserName(task.createdBy)}</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,394 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Plus, Search, X, Users, Trash2 } from 'lucide-react';
import { queryClient } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
import type { ReminderRecipient } from '@shared/schema';
import type { UserSummary, OrgRole } from './types';
interface ReminderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
taskIdNumber: number;
users: UserSummary[];
reminderOrgRolesData?: { success: boolean; roles: OrgRole[] };
}
export function ReminderDialog({ open: showReminderDialog, onOpenChange: setShowReminderDialog, taskIdNumber, users, reminderOrgRolesData }: ReminderDialogProps) {
const { toast } = useToast();
const { user: currentUser } = useAuth();
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);
// Запрос напоминаний задачи
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);
}
};
// Все опции для выбора получателей (должны быть до 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[] = 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, users, 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]);
return (
<>
{/* 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-[11px] px-1.5 py-0">
{rec.type === 'user'
? (users.find((u: any) => u.id === rec.userId)?.fullName || `Пользователь №${rec.userId}`)
: rec.type === 'orgRole'
? (() => {
const roleName = reminderOrgRolesData?.roles?.find(role => role.id === rec.roleId)?.name;
return roleName ? `Роль: ${roleName}` : `Роль организации №${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-[11px] 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="ml-0.5 inline-flex items-center justify-center w-6 h-6 -my-1 rounded relative after:absolute after:-inset-1 after:content-[''] text-muted-foreground hover:text-foreground"
>
<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-[11px] 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>
</>
);
}

View File

@@ -0,0 +1,85 @@
import { Link } from 'wouter';
import { ChevronRight, ListTree } from 'lucide-react';
import type { TaskData } from '@/components/task-fields/FieldValueRow';
interface SubtasksTabContentProps {
subtasks: Array<TaskData & { formId?: number }>;
taskIdNumber: number;
formId: number;
formName?: string;
formNamesMap: Record<number, string>;
}
export 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>
);
}

View File

@@ -0,0 +1,90 @@
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { FieldValueRow } from '@/components/task-fields/FieldValueRow';
import type { FormFieldData, TaskData, TaskFieldValueData } from '@/components/task-fields/FieldValueRow';
import { useOfflineSyncContext } from '@/contexts/OfflineSyncContext';
import type { UserSummary } from './types';
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>>>;
}
export const TabFieldsContent = ({
tabId,
hasTabs,
fields,
fieldValues,
taskIdNumber,
formId,
task,
users,
roles,
fieldValuesMap,
conditionContextStatuses,
isTaskFinal,
isFieldVisibleForTask,
isFieldImmutableForTask,
fieldErrors,
setFieldErrors,
}: 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; })}
/>
</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>
);
};

View File

@@ -0,0 +1,318 @@
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Badge } from '@/components/ui/badge';
import { Plus, Search, X, Users, Shield, UserCheck, UserX, CheckCircle, Bot } from 'lucide-react';
import { apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import type { TaskRoleData, TaskAccessRecord, TransitionApprovalEntry, OrgRole } from './types';
export 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; botId?: number | 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 focus-within:ring-1 focus-within:ring-ring">
<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-sm 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 inline-flex items-center justify-center w-6 h-6 -my-1 rounded relative after:absolute after:-inset-1 after:content-[''] 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-[11px] 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="inline-flex items-center justify-center w-6 h-6 rounded relative after:absolute after:-inset-2 after:content-[''] text-muted-foreground hover:text-destructive" 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-[11px] 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-[11px] font-medium bg-muted text-muted-foreground">Упоминание</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="inline-flex items-center justify-center w-6 h-6 rounded relative after:absolute after:-inset-2 after:content-[''] text-muted-foreground hover:text-destructive" 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-success-foreground" />
Согласования переходов
<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-success-foreground mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs flex items-center gap-1.5 flex-wrap">
{entry.botId != null && (
<Bot className="w-3 h-3 text-muted-foreground" aria-label="Действие бота" />
)}
<span className="font-medium">{entry.changedByName || '—'}</span>
{entry.metadata?.source === 'mcp' && (
<Badge variant="outline" className="text-[11px] h-4 px-1">MCP</Badge>
)}
{entry.metadata?.source === 'api' && (
<Badge variant="outline" className="text-[11px] h-4 px-1">API</Badge>
)}
{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>
);
}

View File

@@ -0,0 +1,235 @@
import { useMemo } from 'react';
import { parseDetailLayout } from '@/layouts';
import type { LayoutPresetProps } from '@/layouts/types';
import { RelatedTasksTab } from '@/components/RelatedTasksTab';
import { TaskHistory } from '@/components/TaskHistory';
import TaskRegularTable from '@/components/TaskRegularTable';
import { DeclarativeTabRenderer } from '@/components/tabs/DeclarativeTabRenderer';
import { JsComponentRenderer } from '@/components/tabs/JsComponentRenderer';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import type { TableColumnDef } from '@shared/schema';
import type { FormFieldData, TaskData, TaskFieldValueData } from '@/components/task-fields/FieldValueRow';
import { InlineSystemFields } from './InlineSystemFields';
import { TabFieldsContent } from './TabFieldsContent';
import { SubtasksTabContent } from './SubtasksTabContent';
import type { FormData, FormTab, FormStatus, FormTransition, TabModuleInfo, TaskAssigneeEntry, UserSummary, OrgRole } from './types';
export 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;
// Слоты расширения для кастомного layout/пресета: переходы статусов и исполнители
transitions: FormTransition[];
assignees: TaskAssigneeEntry[];
}
export 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,
transitions,
assignees,
}: 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;
// Представление области полей: пресет (@preset:<id>) / JS-код / стандартный вид
const detailLayout = useMemo(() => parseDetailLayout(form?.detailLayoutCode), [form?.detailLayoutCode]);
const hasCustomLayout = detailLayout.kind !== 'standard';
const customLayoutExtraCtx = useMemo(() => {
if (!hasCustomLayout || !form) return undefined;
return {
fields,
statuses,
users,
tabs: formTabs,
form: { id: form.id, name: form.name },
transitions,
assignees,
};
}, [hasCustomLayout, form, fields, statuses, users, formTabs, transitions, assignees]);
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);
// Пропсы пресета представления (per-field редактирование через FieldValueRow)
const presetProps = useMemo<LayoutPresetProps | null>(() => {
if (detailLayout.kind !== 'preset' || !form) return null;
return {
task,
formId,
form: { id: form.id, name: form.name },
fields,
fieldValues,
fieldValuesMap,
tabs: formTabs,
statuses,
users,
roles,
transitions,
assignees,
tabId: taskFieldsTabId,
mode: 'desktop',
readOnly: isTaskFinal,
conditionContextStatuses,
isFieldVisibleForTask,
fieldErrors,
setFieldErrors,
};
}, [detailLayout, form, task, formId, fields, fieldValues, fieldValuesMap, formTabs, statuses, users, roles, transitions, assignees, taskFieldsTabId, isTaskFinal, conditionContextStatuses, isFieldVisibleForTask, fieldErrors, setFieldErrors]);
// Компонент выбранного пресета (модульный уровень — без remount-инварианта)
const PresetComponent = detailLayout.kind === 'preset' ? detailLayout.preset.component : undefined;
return (
<>
{selectedTabId === 'subtasks' ? (
<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 ? (
PresetComponent && presetProps ? (
<ErrorBoundary>
<PresetComponent {...presetProps} />
</ErrorBoundary>
) : (
<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}
/>
)}
<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}
/>
</>
)}
</>
);
}

View File

@@ -0,0 +1,189 @@
import { useLocation } from 'wouter';
import type { UseMutationResult } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { ArrowLeft, X, Check, Bell, EyeOff, Eye, Trash2, Lock, Clock } from 'lucide-react';
import { statusBadgeStyle, getContrastTextColor } from '@/lib/statusColor';
import { TaskQrButton } from '@/components/TaskQrButton';
import { useAuth } from '@/hooks/useAuth';
import { isAdmin } from '@/lib/permissions';
import type { TaskData } from '@/components/task-fields/FieldValueRow';
import type { Task } from '@shared/schema';
import type { FormStatus, FormTransition } from './types';
import { TaskTitleInline } from './TaskTitleInline';
interface TaskDetailHeaderProps {
task: TaskData;
taskIdNumber: number;
formId: number;
isTaskFinal: boolean;
currentStatus?: FormStatus;
isTransitionPending: boolean;
approvalTransitions: FormTransition[];
executeTransitionMutation: UseMutationResult<{ success: boolean; task: Task }, Error, { transitionId: number; action?: 'approve' | 'reject' }>;
hideStatus?: { success: boolean; isHidden: boolean };
toggleHideMutation: UseMutationResult<void, Error, void>;
onShowReminderDialog: (open: boolean) => void;
onShowDeleteDialog: (open: boolean) => void;
}
export function TaskDetailHeader({
task,
taskIdNumber,
formId,
isTaskFinal,
currentStatus,
isTransitionPending,
approvalTransitions,
executeTransitionMutation,
hideStatus,
toggleHideMutation,
onShowReminderDialog: setShowReminderDialog,
onShowDeleteDialog: setShowDeleteDialog,
}: TaskDetailHeaderProps) {
const [, setLocation] = useLocation();
const { user: currentUser } = useAuth();
// Компонент статуса задачи: сплошная заливка цветом статуса + контрастный текст (WCAG)
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={statusBadgeStyle(currentStatus.color)}
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 (
<>
{/* Компактная шапка */}
<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>
<TaskTitleInline task={task} taskIdNumber={taskIdNumber} formId={formId} isTaskFinal={isTaskFinal} />
</div>
</div>
</div>
{/* Статус и действия в шапке */}
<div className="flex items-center gap-3 md:gap-2 flex-wrap md:justify-end shrink-0">
{/* Кнопки согласования */}
{approvalTransitions.map((transition) => {
const transitionDisabled = executeTransitionMutation.isPending || isTransitionPending;
// Дефолты с контрастом ≥4.5 к белому тексту; для произвольного цвета текст подбирается по яркости
const approveColor = transition.approveButtonColor || '#15803d';
const rejectColor = transition.rejectButtonColor || '#b91c1c';
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: rejectColor, color: getContrastTextColor(rejectColor) }}
className="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: approveColor, color: getContrastTextColor(approveColor) }}
className="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: rejectColor, color: getContrastTextColor(rejectColor) }}
className="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>
);
})}
<button
onClick={() => setShowReminderDialog(true)}
className="flex items-center justify-center w-9 h-9 md:w-7 md: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-9 h-9 md:w-7 md: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 />
{/* Удаление отделено от нейтральных иконок и стоит в конце ряда */}
{isAdmin(currentUser) && (
<button
onClick={() => setShowDeleteDialog(true)}
className="ms-auto flex items-center justify-center w-9 h-9 md:w-7 md:h-7 rounded text-destructive hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Удалить задачу"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,225 @@
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
import { RelatedTasksTab } from '@/components/RelatedTasksTab';
import { TaskHistory } from '@/components/TaskHistory';
import TaskRegularTable from '@/components/TaskRegularTable';
import { DeclarativeTabRenderer } from '@/components/tabs/DeclarativeTabRenderer';
import type { TableColumnDef } from '@shared/schema';
import type { FormFieldData, TaskData, TaskFieldValueData } from '@/components/task-fields/FieldValueRow';
import { InlineSystemFields } from './InlineSystemFields';
import { TabFieldsContent } from './TabFieldsContent';
import { SubtasksTabContent } from './SubtasksTabContent';
import type { FormData, FormTab, TabModuleInfo, UserSummary, OrgRole } from './types';
interface TaskMobileAccordionProps {
openAccordionValue: string;
onOpenAccordionChange: (value: string) => void;
hasTabs: boolean;
formTabs: FormTab[];
modulesData?: { modules: TabModuleInfo[] };
systemFieldsTabId?: number | null;
task: TaskData;
taskIdNumber: number;
formId: number;
form: FormData;
getUserName: (id: number) => string;
isTaskFinal: boolean;
users: UserSummary[];
reminderOrgRolesData?: { success: boolean; roles: OrgRole[] };
fields: FormFieldData[];
fieldValues: TaskFieldValueData[];
fieldValuesMap: Record<string, unknown>;
conditionContextStatuses: Array<{ id: number; name?: string; position: number; isFinal?: boolean | null }>;
isFieldVisibleForTask: (field: FormFieldData) => boolean;
isFieldImmutableForTask: (field: FormFieldData) => boolean;
fieldErrors: Record<number, string>;
setFieldErrors: React.Dispatch<React.SetStateAction<Record<number, string>>>;
orderedSystemTabs: string[];
subtasks: Array<TaskData & { formId?: number }>;
formNamesMap: Record<number, string>;
}
export function TaskMobileAccordion({
openAccordionValue,
onOpenAccordionChange: setOpenAccordionValue,
hasTabs,
formTabs,
modulesData,
systemFieldsTabId,
task,
taskIdNumber,
formId,
form,
getUserName,
isTaskFinal,
users,
reminderOrgRolesData,
fields,
fieldValues,
fieldValuesMap,
conditionContextStatuses,
isFieldVisibleForTask,
isFieldImmutableForTask,
fieldErrors,
setFieldErrors,
orderedSystemTabs,
subtasks,
formNamesMap,
}: TaskMobileAccordionProps) {
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 };
};
return (
<Accordion type="single" collapsible value={openAccordionValue} onValueChange={setOpenAccordionValue}>
{/* Fallback: форма без вкладок — системные поля и все поля одной секцией «Задача» */}
{!hasTabs && (
<AccordionItem value="task-fields" 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">
<InlineSystemFields
task={task}
taskId={taskIdNumber}
formId={formId}
getUserName={getUserName}
readOnly={isTaskFinal}
hiddenSystemFields={form.hiddenSystemFields}
users={users}
isTaskFinal={isTaskFinal}
/>
<TabFieldsContent
tabId={undefined}
hasTabs={false}
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}
/>
</AccordionContent>
</AccordionItem>
)}
{/* Пользовательские вкладки формы */}
{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}
/>
)}
{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}
/>
)}
</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>
);
}

View File

@@ -0,0 +1,169 @@
import { ChevronLeft, ChevronRight, Maximize2, Minimize2, FileText, MessageSquare } from 'lucide-react';
import { SYSTEM_TAB_ICONS, SYSTEM_TAB_LABELS } from './systemTabs';
import type { FormTab } from './types';
interface TaskTabsBarProps {
isMobile: boolean;
chatEnabled: boolean;
chatLayout: 'left' | 'tab';
expandedPane: 'none' | 'chat' | 'task';
onToggleExpand: (pane: 'chat' | 'task') => void;
formTabs: FormTab[];
orderedSystemTabs: string[];
subtasksCount: number;
selectedTabId: string;
onSelectTab: (value: string) => void;
isTaskFinal: boolean;
tabsScrollRef: (el: HTMLDivElement | null) => void;
tabsScrollEl: HTMLDivElement | null;
showLeftArrow: boolean;
showRightArrow: boolean;
}
export function TaskTabsBar({
isMobile,
chatEnabled,
chatLayout,
expandedPane,
onToggleExpand: toggleExpand,
formTabs,
orderedSystemTabs,
subtasksCount,
selectedTabId,
onSelectTab: setSelectedTabId,
isTaskFinal,
tabsScrollRef,
tabsScrollEl,
showLeftArrow,
showRightArrow,
}: TaskTabsBarProps) {
// Кнопка вкладки
const TabBtn = ({ value, icon: Icon, children, count }: {
value: string;
icon?: React.ElementType;
children: React.ReactNode;
count?: number;
}) => (
<button
onClick={() => setSelectedTabId(value)}
aria-current={selectedTabId === value ? 'page' : undefined}
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-[11px] bg-muted rounded-full px-1 leading-4">{count}</span>
)}
</button>
);
return (
<>
{/* Горизонтальная полоса вкладок — десктоп */}
{!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-sm 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' ? subtasksCount : 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-sm text-muted-foreground hover:text-foreground transition-colors"
onClick={() => tabsScrollEl?.scrollBy({ left: 160, behavior: 'smooth' })}
>
<ChevronRight className="w-3 h-3" />
</button>
)}
</div>
)}
</div>
)}
{/* Мобильный таб-бар — только Задача / Чат */}
{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>
)}
</>
);
}

View File

@@ -0,0 +1,126 @@
import { useState, useEffect, useRef } from 'react';
import { queryClient, OfflineQueuedError, taskUpdateRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { cn } from '@/lib/utils';
import { saveTask } from '@/lib/tasksCache';
import type { TaskData } from '@/components/task-fields/FieldValueRow';
import type { TaskDetailResponse } from './types';
// Заголовок задачи в шапке с per-field редактированием (клик → инпут, blur/Enter — сохранить, Esc — отмена)
export function TaskTitleInline({ task, taskIdNumber, formId, isTaskFinal }: { task: TaskData; taskIdNumber: number; formId: number; isTaskFinal: boolean }) {
const { toast } = useToast();
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(task.title);
const draftRef = useRef(task.title);
const wrapperRef = useRef<HTMLDivElement>(null);
const lastTitleTapRef = useRef<number>(0);
// Пока не редактируем — синхронизируем черновик с актуальным заголовком
useEffect(() => {
if (!isEditing) {
setDraft(task.title);
draftRef.current = task.title;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [task.title, isEditing]);
const saveTitle = async (val: string) => {
if (!isEditing) return;
setIsEditing(false);
const title = val.trim();
if (!title) {
toast({ title: 'Название задачи обязательно', variant: 'destructive' });
setDraft(task.title);
draftRef.current = task.title;
return;
}
if (title === task.title) return;
// Optimistic-обновление кэша детали задачи
const prev = queryClient.getQueryData<TaskDetailResponse>(['/api/tasks', taskIdNumber, 'detail']);
if (prev) {
const merged: TaskDetailResponse = { ...prev, task: { ...prev.task, title } };
queryClient.setQueryData(['/api/tasks', taskIdNumber, 'detail'], merged);
saveTask(taskIdNumber, merged).catch(() => {});
}
try {
await taskUpdateRequest(taskIdNumber, { title }, task.updatedAt, { title: task.title });
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'] });
} catch (err: unknown) {
if (err instanceof OfflineQueuedError) return;
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskIdNumber, 'detail'] });
const msg = err instanceof Error ? err.message : 'Не удалось сохранить заголовок. Проверьте соединение и попробуйте ещё раз.';
toast({ title: 'Ошибка сохранения', description: msg, variant: 'destructive' });
}
};
// Клик вне инпута — сохранить, Esc — отмена
useEffect(() => {
if (!isEditing) return;
const handleOutsideClick = (e: MouseEvent) => {
if (wrapperRef.current?.contains(e.target as Node)) return;
saveTitle(draftRef.current);
};
document.addEventListener('click', handleOutsideClick);
return () => document.removeEventListener('click', handleOutsideClick);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEditing]);
if (isEditing) {
return (
<div ref={wrapperRef} className="min-w-0 flex-1 animate-in fade-in-0 duration-150">
<input
autoFocus
className="text-base md:text-sm font-medium border border-input rounded px-1.5 h-7 leading-6 bg-background focus:outline-none focus:ring-1 focus:ring-ring min-w-0 w-full"
value={draft}
aria-label="Название задачи"
onChange={e => {
draftRef.current = e.target.value;
setDraft(e.target.value);
}}
onBlur={() => saveTitle(draftRef.current)}
onKeyDown={e => {
if (e.key === 'Enter') { e.preventDefault(); saveTitle(draftRef.current); }
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
setDraft(task.title);
draftRef.current = task.title;
setIsEditing(false);
}
}}
data-testid="input-task-title"
/>
</div>
);
}
const enterTitleEdit = () => {
if (isTaskFinal) return;
setDraft(task.title);
draftRef.current = task.title;
setIsEditing(true);
};
return (
<div
className={cn('group flex items-center gap-1 min-w-0', !isTaskFinal && 'cursor-text')}
title={!isTaskFinal ? 'Двойной клик для редактирования' : undefined}
onDoubleClick={enterTitleEdit}
onTouchEnd={(e) => {
if (isTaskFinal) return;
const now = Date.now();
if (now - lastTitleTapRef.current < 300) {
e.stopPropagation();
enterTitleEdit();
}
lastTitleTapRef.current = now;
}}
>
<h1 className={`text-base font-semibold md:text-lg break-words md:truncate ${isTaskFinal ? 'line-through text-muted-foreground' : 'text-foreground'}`} title={task.title} data-testid="text-task-title">
{task.title}
</h1>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { ListTree, LinkIcon, MessageSquare, History, Shield } from 'lucide-react';
export const SYSTEM_TAB_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
subtasks: ListTree,
related: LinkIcon,
chat: MessageSquare,
history: History,
access: Shield,
};
export const SYSTEM_TAB_LABELS: Record<string, string> = {
subtasks: 'Подзадачи',
related: 'Связанные',
chat: 'Чат',
history: 'История',
access: 'Доступы',
};

View File

@@ -0,0 +1,146 @@
import type { TableColumnDef } from '@shared/schema';
import type { FormFieldData, TaskData, TaskFieldValueData } from '@/components/task-fields/FieldValueRow';
import type { CachedTaskDetail } from '@/hooks/useTaskWithCache';
export 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;
}
export 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;
}
export interface FormTab {
id: number;
formId: number;
name: string;
code: string;
type: string;
position: number;
tableColumns?: TableColumnDef[];
}
export interface TaskFieldValuesResponse {
fieldValues: TaskFieldValueData[];
}
export interface FormStatus {
id: number;
formId: number;
name: string;
color: string;
position: number;
isInitial?: boolean;
isFinal?: boolean;
}
export 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[];
}
// Запись об исполнителе задачи (task_assignees) — передаётся в слоты кастомного layout
export interface TaskAssigneeEntry {
id: number;
userId: number;
delegatedFromUserId?: number | null;
pendingReview?: boolean;
user: { id: number; firstName: string | null; lastName: string | null; email: string };
}
export interface UserSummary {
id: number;
email: string;
firstName: string | null;
lastName: string | null;
middleName?: string | null;
fullName?: string;
}
export 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;
}
export interface TaskRoleData {
id: number;
taskId: number;
roleId: number;
organizationId: number;
createdAt: string;
role: { id: number; name: string; parentId: number | null };
}
export 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 };
}
export interface TransitionApprovalEntry {
id: number;
changedBy: number | null;
changedByName: string | null;
// Атрибуция бота (действие выполнено по API-ключу бота); null — действие пользователя
botId?: number | null;
createdAt: string;
metadata: {
statusName?: string;
roleName?: string | null;
roleId?: number | null;
statusId?: number;
actionText?: string;
source?: string;
} | null;
}
export interface OrgRole {
id: number;
name: string;
description?: string | null;
parentId?: number | null;
}

View File

@@ -0,0 +1,145 @@
import { useMemo, useCallback } from 'react';
import {
computeFieldState,
buildRequiredConditionContext,
buildClientFieldValues,
getAllConditionFields,
type FieldConditionContext,
type ClientFormField,
type FieldState,
} from '@/lib/fieldConditions';
import type { FormFieldData, TaskFieldValueData } from '@/components/task-fields/FieldValueRow';
import type { TaskDetailResponse, OrgRole } from './types';
// Условные хуки полей задачи (видимость/immutable/autofill-ОГРН).
// ВАЖНО: хук вызывается ДО всех early return в TaskDetail,
// иначе при переходе от экрана загрузки к данным ломается порядок хуков (белый экран).
export function useTaskFieldConditions(
effectiveDetailData: TaskDetailResponse | null | undefined,
reminderOrgRolesData: { success: boolean; roles: OrgRole[] } | undefined,
) {
// Коды полей, которые являются 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]
);
return {
fields,
fieldValues,
users: hookUsers,
formStatuses: hookFormStatuses,
currentStatusId: hookTaskCurrentStatusId,
conditionContextStatuses,
isFieldVisibleForTask,
isFieldImmutableForTask,
companyAutofillTargetCodes,
};
}

File diff suppressed because it is too large Load Diff