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:
75
client/src/components/task-detail/DeleteTaskDialog.tsx
Normal file
75
client/src/components/task-detail/DeleteTaskDialog.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
632
client/src/components/task-detail/InlineSystemFields.tsx
Normal file
632
client/src/components/task-detail/InlineSystemFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
394
client/src/components/task-detail/ReminderDialog.tsx
Normal file
394
client/src/components/task-detail/ReminderDialog.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
client/src/components/task-detail/SubtasksTabContent.tsx
Normal file
85
client/src/components/task-detail/SubtasksTabContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
client/src/components/task-detail/TabFieldsContent.tsx
Normal file
90
client/src/components/task-detail/TabFieldsContent.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
318
client/src/components/task-detail/TaskAccessTab.tsx
Normal file
318
client/src/components/task-detail/TaskAccessTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
235
client/src/components/task-detail/TaskContent.tsx
Normal file
235
client/src/components/task-detail/TaskContent.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
189
client/src/components/task-detail/TaskDetailHeader.tsx
Normal file
189
client/src/components/task-detail/TaskDetailHeader.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
225
client/src/components/task-detail/TaskMobileAccordion.tsx
Normal file
225
client/src/components/task-detail/TaskMobileAccordion.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
client/src/components/task-detail/TaskTabsBar.tsx
Normal file
169
client/src/components/task-detail/TaskTabsBar.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
126
client/src/components/task-detail/TaskTitleInline.tsx
Normal file
126
client/src/components/task-detail/TaskTitleInline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
client/src/components/task-detail/systemTabs.ts
Normal file
17
client/src/components/task-detail/systemTabs.ts
Normal 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: 'Доступы',
|
||||
};
|
||||
146
client/src/components/task-detail/types.ts
Normal file
146
client/src/components/task-detail/types.ts
Normal 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;
|
||||
}
|
||||
145
client/src/components/task-detail/useTaskFieldConditions.ts
Normal file
145
client/src/components/task-detail/useTaskFieldConditions.ts
Normal 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
Reference in New Issue
Block a user