diff --git a/IMPLEMENTATION_LOG.md b/IMPLEMENTATION_LOG.md index 594c02f..3d90f27 100644 --- a/IMPLEMENTATION_LOG.md +++ b/IMPLEMENTATION_LOG.md @@ -358,3 +358,13 @@ - Что изменено: `client/src/pages/Chat.tsx` → 8 файлов в `client/src/components/chat/`: chat-types.ts, chat-utils.ts, MessageBubble.tsx (пузырь + кнопки ботов), NewConversationDialog.tsx, GroupMembersDialog.tsx, HighlightMatch.tsx, TypingIndicator.tsx, useChatController.ts (вся логика страницы дословно). В Chat.tsx — только деструктуризация хука + прежний JSX. - Как проверялось: ПРОГРАММНАЯ сверка с оригиналом (git show): тело логики == useChatController посимвольно, рендер == оригиналу (кроме замены inline-блока на ). Чеклист real-time фич дословно на месте (SSE typing, reportActiveChat в useLayoutEffect, polling fallback 10с, оптимистичная автоотметка прочтения с debounce, кнопки ботов с «нажато»). check/lint/build чисто; vitest 104/104. - Влияние на поиск/UX: нет (поведение идентично доказанно). + +--- + +## [2.2b] Разбиение TaskDetail.tsx (3278 → 804 строки) + +- Статус: ✅ done +- Зачем: см. 2.2a. БЕЗ смены поведения. +- Что изменено: `client/src/pages/TaskDetail.tsx` → 14 файлов в `client/src/components/task-detail/`: types.ts, systemTabs.ts, TabFieldsContent, TaskAccessTab (мёртвый код, сохранён для паритета — кандидат на удаление), InlineSystemFields (ответственные, «На себя», срок, замещение), SubtasksTabContent, TaskContent (роутер вкладок + слоты пресетов), TaskTitleInline (хуки в исходном порядке, фикс условного useRef сохранён), useTaskFieldConditions, ReminderDialog, DeleteTaskDialog, TaskDetailHeader, TaskTabsBar, TaskMobileAccordion. В TaskDetail — композиция + хуки верхнего уровня (SSE, переходы, просмотры). +- Как проверялось: посимвольная сверка — 32 несовпавшие строки, все объяснены (22 — префикс export, 4 — разбитые импорты, 5 — адаптации пропсов, 1 — комментарий). Чеклист: per-field editing, «На себя», просмотры, geo/contact в потоке, пресеты, порядок хуков 1:1. check/lint/build чисто; vitest 104/104. +- Влияние на поиск/UX: нет. diff --git a/client/src/components/task-detail/DeleteTaskDialog.tsx b/client/src/components/task-detail/DeleteTaskDialog.tsx new file mode 100644 index 0000000..842743e --- /dev/null +++ b/client/src/components/task-detail/DeleteTaskDialog.tsx @@ -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 */} + + + + Удалить задачу «{taskTitle}»? + + + Задача будет удалена безвозвратно вместе со всеми сообщениями, файлами и историей изменений. + + + + + + + + + + + ); +} diff --git a/client/src/components/task-detail/InlineSystemFields.tsx b/client/src/components/task-detail/InlineSystemFields.tsx new file mode 100644 index 0000000..c0cf124 --- /dev/null +++ b/client/src/components/task-detail/InlineSystemFields.tsx @@ -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(null); + const [assigneeDropdownOpen, setAssigneeDropdownOpen] = useState(false); + const [assigneeSearch, setAssigneeSearch] = useState(''); + const assigneeTriggerRef = useRef(null); + + // Per-field редактирование срока: локальный черновик даты/времени + const [isEditingDue, setIsEditingDue] = useState(false); + const [dueDateDraft, setDueDateDraft] = useState(''); + const [dueTimeDraft, setDueTimeDraft] = useState(''); + const dueDraftRef = useRef({ date: '', time: '' }); + const dueEditorRef = useRef(null); + + // Двойное касание на мобильных — вход в редактирование срока + const lastDueTapRef = useRef(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(['/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(['/api/tasks', tid, 'detail']); + if (prev) { + const merged: TaskDetailResponse = { ...prev, task: { ...prev.task, ...(data.task as unknown as TaskData) } }; + queryClient.setQueryData(['/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(['/api/tasks', tid, 'detail']); + if (prev) { + const merged: TaskDetailResponse = { ...prev, task: { ...prev.task, ...(data.task as unknown as TaskData) } }; + queryClient.setQueryData(['/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) => ( +
+ {!readOnly ? ( + + ) : null} +
+ ); + + const emptySlot =
; + + 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 ( +
+ {/* Баннер ознакомления: задача обновлена заместителем, пока пользователь отсутствовал */} + {pendingReviewAssignee && ( +
+ + Задача обновлена вашим заместителем. Ознакомьтесь и подтвердите. + +
+ )} + {/* Assignee + Roles */} + {!hidden.has('assignedTo') && ( +
+ {!readOnly + ? leftSlot(() => { setAssigneeDropdownOpen(v => !v); setAssigneeSearch(''); }) + : emptySlot} + Ответственный: + {!readOnly && currentUser && !assignedUserIds.has(currentUser.id) && task.assignedTo !== currentUser.id && ( + + )} +
{ + 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 */} +
+ {hasNoAssignees && ( + task.assignedTo + ? + + {getUserName(task.assignedTo)} + + : !readOnly + ? Не назначен + : null + )} + {assignedTaskUsers.map(a => ( + + + {getAssigneeName(a)} + {a.delegatedFromUserId != null && ( + + за {getUserName(a.delegatedFromUserId)} + + )} + {!readOnly && ( + + )} + + ))} + {assignedTaskRoles.map(tr => ( + + + {tr.role.name} + {isAdminUser && !readOnly && ( + + )} + + ))} +
+ {/* Combined dropdown — unified search */} + {assigneeDropdownOpen && !readOnly && ( +
+
+ + setAssigneeSearch(e.target.value)} + placeholder="Поиск…" + className="flex-1 text-xs bg-transparent outline-none" + /> +
+
+ {/* Users not yet assigned */} + {filteredUsers.length > 0 && ( + <> +
Пользователи
+ {filteredUsers.map((u: any) => ( + + ))} + + )} + {/* Roles — admin only */} + {isAdminUser && filteredOrgRoles.length > 0 && ( + <> +
Роли
+ {filteredOrgRoles.map(role => ( + + ))} + + )} + {filteredUsers.length === 0 && (!isAdminUser || filteredOrgRoles.length === 0) && ( +
+ {assigneeSearch.trim() ? `По запросу «${assigneeSearch.trim()}» ничего не найдено` : 'Нет доступных пользователей'} +
+ )} +
+
+ )} +
+
+ )} + + {/* 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 = ( + + {effectiveDueDate + ? (() => { + const overdue = !readOnly && !isDuePending && new Date(effectiveDueDate) < new Date(); + return ( + + {(() => { + 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 && {timeStr}} + + ); + })()} + {overdue && } + + ); + })() + : Указать срок… + } + {isDuePending && ( + + + + )} + + ); + const dueDateEditor = ( +
+ { + 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); } + }} + /> + { + 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 && ( + + )} +
+ ); + return ( +
+ {emptySlot} + Срок: + {isEditingDue ? ( + dueDateEditor + ) : ( +
{ + if (readOnly) return; + if (isInteractiveClickTarget(e.target)) return; + enterDueEdit(); + }} + onTouchEnd={handleDueDoubleTap} + > + {dueDateReadView} +
+ )} +
+ ); + })()} + + {/* Created (read-only) */} + {!hidden.has('createdAt') && ( +
+ {emptySlot} + Создана: + + {task.createdAt + ? new Date(task.createdAt).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) + : ''} + +
+ )} + + {/* Completed at (read-only, shown only when task is in final status) */} + {isTaskFinal && !hidden.has('completedAt') && ( +
+ {emptySlot} + Завершена: + + {task.completedAt + ? new Date(task.completedAt).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) + : '—'} + +
+ )} + + {/* Creator (read-only) */} + {!hidden.has('createdBy') && task.createdBy && ( +
+ {emptySlot} + Автор: + {getUserName(task.createdBy)} +
+ )} + +
+ ); +} diff --git a/client/src/components/task-detail/ReminderDialog.tsx b/client/src/components/task-detail/ReminderDialog.tsx new file mode 100644 index 0000000..2bc4a32 --- /dev/null +++ b/client/src/components/task-detail/ReminderDialog.tsx @@ -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(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([]); + const [recipientSearch, setRecipientSearch] = useState(''); + const [recipientDropdownOpen, setRecipientDropdownOpen] = useState(false); + const recipientInputRef = useRef(null); + const recipientContainerRef = useRef(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 = {}; + 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 */} + + + + Напоминания по задаче + Создание и управление напоминаниями для этой задачи + + + {/* Existing reminders list */} + {reminders.length > 0 && ( +
+

Активные напоминания

+ {reminders.map((r) => ( +
+
+
+ {new Date(r.remindAt).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })} +
+ {r.note &&
{r.note}
} +
+ {r.recipients.map((rec, i) => ( + + {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}`} + + ))} +
+
+ +
+ ))} +
+ )} + + {/* New reminder form */} +
+

Новое напоминание

+
+ + { + 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" + /> +
+
+ +