diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index e1c44b3..48b9bb3 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -6,7 +6,7 @@ import { Inbox, Clock, Search, ChevronDown, ChevronRight, User, Layers, CheckCircle2, X, AlertCircle, ArrowUp, ArrowDown, BellOff, Settings2, Plus, GripVertical, WifiOff, - EyeOff, Eye, ArrowRightLeft, + EyeOff, Eye, ArrowRightLeft, UserCheck, } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; @@ -125,6 +125,8 @@ function getGroupKey(task: HomeTask, rule: GroupRule): string | null { const labels: string[] = []; if (reasons.includes('assignee')) labels.push('Ответственный'); if (reasons.includes('notification')) labels.push('Уведомления'); + if (reasons.includes('delegation')) labels.push('Замещение'); + if (reasons.includes('delegation_review')) labels.push('На ознакомлении'); if (reasons.includes('role')) { if (task.roleNames && task.roleNames.length > 0) task.roleNames.forEach(r => labels.push(r)); else labels.push('По роли'); @@ -230,6 +232,8 @@ function ReasonLabel({ reasons, roleNames }: { reasons?: string[]; roleNames?: s const parts: string[] = []; if (reasons.includes('assignee')) parts.push('Ответственный'); if (reasons.includes('notification')) parts.push('Уведомл.'); + if (reasons.includes('delegation')) parts.push('Замещение'); + if (reasons.includes('delegation_review')) parts.push('На ознакомлении'); if (reasons.includes('role')) { if (roleNames && roleNames.length > 0) parts.push(...roleNames); else parts.push('По роли'); @@ -874,6 +878,7 @@ export default function HomePage() { const [openAccordionValue, setOpenAccordionValue] = useState('inbox'); const [offset, setOffset] = useState(0); const [allTasks, setAllTasks] = useState([]); + const [delegationOnly, setDelegationOnly] = useState(false); const [selectedTaskIds, setSelectedTaskIds] = useState>(new Set()); const [snoozeDialogOpen, setSnoozeDialogOpen] = useState(false); const [snoozeRemindAt, setSnoozeRemindAt] = useState(''); @@ -1064,6 +1069,14 @@ export default function HomePage() { const effectiveTasks = isUsingCache ? inboxCache.tasks : allTasks; const total = isUsingCache ? inboxCache.total : (currentQuery.data?.total ?? 0); + // Клиентский фильтр «Замещение» для вкладки «Входящие» + const displayTasks = useMemo( + () => (delegationOnly && tab === 'inbox' + ? effectiveTasks.filter(t => t.reasons?.includes('delegation')) + : effectiveTasks), + [delegationOnly, tab, effectiveTasks] + ); + // ─── Bulk status change ─────────────────────────────────────────────────────── const selectedTasks = useMemo(() => effectiveTasks.filter(t => selectedTaskIds.has(t.id)), [effectiveTasks, selectedTaskIds]); @@ -1138,6 +1151,7 @@ export default function HomePage() { setTab(newTab); clearAll(); clearSelection(); + setDelegationOnly(false); } const toggleTask = useCallback((taskId: number) => { @@ -1370,11 +1384,11 @@ export default function HomePage() {
{tab === 'inbox' && isGroupActive ? : }
- ) : effectiveTasks.length === 0 ? ( + ) : displayTasks.length === 0 ? (
{emptyState}
) : isGroupActive ? ( setOffset(prev => prev + PAGE_SIZE)} groupRules={groupRules} showRemindAt={tab === 'snoozed'} @@ -1383,7 +1397,7 @@ export default function HomePage() { /> ) : ( setOffset(prev => prev + PAGE_SIZE)} onUnhide={tab === 'hidden' ? (id) => unhideTaskMutation.mutate(id) : undefined} @@ -1447,6 +1461,20 @@ export default function HomePage() { )} + {tab === 'inbox' && ( + + )} + {tab === 'snoozed' && ( Сортировка по времени напоминания @@ -1467,7 +1495,7 @@ export default function HomePage() { {tab === 'inbox' && ( <> -
+
+
{tabContent} diff --git a/client/src/pages/TaskDetail.tsx b/client/src/pages/TaskDetail.tsx index a0d2647..2bfb358 100644 --- a/client/src/pages/TaskDetail.tsx +++ b/client/src/pages/TaskDetail.tsx @@ -2160,7 +2160,7 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde }); // Task assignees (несколько ответственных пользователей) - const { data: taskAssigneesData, refetch: refetchAssignees } = useQuery<{ success: boolean; assignees: Array<{ id: number; userId: number; user: { id: number; firstName: string | null; lastName: string | null; email: string } }> }>({ + 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, @@ -2177,6 +2177,8 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde 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) => { @@ -2232,6 +2234,22 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde 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: () => { @@ -2316,6 +2334,23 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde return (
+ {/* Баннер ознакомления: задача обновлена заместителем, пока пользователь отсутствовал */} + {pendingReviewAssignee && ( +
+ + Задача обновлена вашим заместителем. Ознакомьтесь и подтвердите. + +
+ )} {/* Assignee + Roles */} {!hidden.has('assignedTo') && (
@@ -2363,6 +2398,15 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde {getAssigneeName(a)} + {a.delegatedFromUserId != null && ( + + за {getUserName(a.delegatedFromUserId)} + + )} {!readOnly && (
+ {currentDelegations.length > 0 && ( +
+ + + Замещает: {currentDelegations.map((d) => `${d.delegateName || '—'} (до ${formatDelegationDate(d.dateTo)})`).join(', ')} + +
+ )}
)} + + {/* Замещение пользователя на период отсутствия */} + {canEdit && ( +
+
+ +

Замещение

+
+ + {delegations.length === 0 ? ( +

Замещения не настроены

+ ) : ( +
+ {delegations.map((d) => { + const state = delegationState(d, todayStr); + return ( +
+ {d.delegateName || '—'} + + {d.formId ? (d.formName || `Форма #${d.formId}`) : 'Все формы (по умолчанию)'} + + + с {formatDelegationDate(d.dateFrom)} по {formatDelegationDate(d.dateTo)} + + {state === 'active' && Активно} + {state === 'expired' && Истекло} + {state === 'disabled' && Отключено} +
+ {state === 'active' && ( + + )} + +
+
+ ); + })} +
+ )} + + {/* Форма добавления замещения */} +
+
+ + +
+
+ + +
+
+ + setNewDateFrom(e.target.value)} + /> +
+
+ + setNewDateTo(e.target.value)} + /> +
+ +
+
+ )} {/* Custom tabs */} diff --git a/migrations/0067_user_delegations.sql b/migrations/0067_user_delegations.sql new file mode 100644 index 0000000..396ba60 --- /dev/null +++ b/migrations/0067_user_delegations.sql @@ -0,0 +1,25 @@ +-- Замещение пользователей (delegation/substitution, референс — Pyrus). +-- user_delegations: кто кого замещает, на какой период и (опционально) по какой форме. +-- form_id IS NULL — заместитель по умолчанию для всех форм. +CREATE TABLE IF NOT EXISTS user_delegations ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + delegate_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + form_id INTEGER REFERENCES forms(id) ON DELETE CASCADE, + date_from DATE NOT NULL, + date_to DATE NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS user_delegations_user_active_idx ON user_delegations(user_id, is_active); +CREATE INDEX IF NOT EXISTS user_delegations_org_idx ON user_delegations(organization_id); + +-- task_assignees: признак строки заместителя (delegated_from_user_id — кого замещает) +-- и флаг «на ознакомлении» (pending_review — отсутствующий вернулся в задачу после перехода). +ALTER TABLE task_assignees + ADD COLUMN IF NOT EXISTS delegated_from_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE task_assignees + ADD COLUMN IF NOT EXISTS pending_review BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/server/mcp.ts b/server/mcp.ts index b362ac4..732319f 100644 --- a/server/mcp.ts +++ b/server/mcp.ts @@ -4599,6 +4599,24 @@ To block task creation from task.before_create, set: ctx.result = { allow: false UNION + -- Замещение: пользователь — заместитель отсутствующего исполнителя + SELECT ta_d.task_id, 'delegation' AS reason, NULL::text AS role_name + FROM task_assignees ta_d + JOIN tasks t_d ON t_d.id = ta_d.task_id AND t_d.organization_id = $1 AND t_d.is_completed = false + WHERE ta_d.user_id = $2 AND ta_d.organization_id = $1 + AND ta_d.delegated_from_user_id IS NOT NULL + + UNION + + -- Замещение: задача ждёт ознакомления отсутствующего исполнителя + SELECT ta_r.task_id, 'delegation_review' AS reason, NULL::text AS role_name + FROM task_assignees ta_r + JOIN tasks t_r ON t_r.id = ta_r.task_id AND t_r.organization_id = $1 AND t_r.is_completed = false + WHERE ta_r.user_id = $2 AND ta_r.organization_id = $1 + AND ta_r.pending_review = true + + UNION + SELECT un.task_id, 'notification' AS reason, NULL::text FROM user_notifications un JOIN tasks t2 ON t2.id = un.task_id AND t2.organization_id = $1 AND t2.is_completed = false diff --git a/server/routes/home.routes.ts b/server/routes/home.routes.ts index acf34db..51f0928 100644 --- a/server/routes/home.routes.ts +++ b/server/routes/home.routes.ts @@ -76,6 +76,24 @@ homeRouter.get('/api/home/inbox', UNION + -- Замещение: я — заместитель отсутствующего исполнителя + SELECT ta_d.task_id, 'delegation' AS reason, NULL::text AS role_name + FROM task_assignees ta_d + JOIN tasks t_d ON t_d.id = ta_d.task_id AND t_d.organization_id = $1 AND t_d.is_completed = false + WHERE ta_d.user_id = $2 AND ta_d.organization_id = $1 + AND ta_d.delegated_from_user_id IS NOT NULL + + UNION + + -- Замещение: я — отсутствующий исполнитель, задача ждёт моего ознакомления + SELECT ta_r.task_id, 'delegation_review' AS reason, NULL::text AS role_name + FROM task_assignees ta_r + JOIN tasks t_r ON t_r.id = ta_r.task_id AND t_r.organization_id = $1 AND t_r.is_completed = false + WHERE ta_r.user_id = $2 AND ta_r.organization_id = $1 + AND ta_r.pending_review = true + + UNION + SELECT un.task_id, 'notification' AS reason, NULL::text FROM user_notifications un JOIN tasks t2 ON t2.id = un.task_id AND t2.organization_id = $1 AND t2.is_completed = false diff --git a/server/routes/task-roles.routes.ts b/server/routes/task-roles.routes.ts index 8fa78a4..153fe9d 100644 --- a/server/routes/task-roles.routes.ts +++ b/server/routes/task-roles.routes.ts @@ -6,6 +6,7 @@ import { evaluateAutoTransitions } from "../utils/auto-transitions"; import { tasksMinimalCache } from "../utils/cache"; import { eventBus } from "./shared"; import { notifyTaskAssigned } from "../utils/notifyAssignee"; +import { acknowledgeDelegationReview } from "../services/delegation.service"; export function registerTaskRoleRoutes(router: ReturnType): void { // === TASK ROLES === @@ -242,6 +243,34 @@ export function registerTaskRoleRoutes(router: ReturnType { + try { + const taskId = parseInt(req.params.id); + if (isNaN(taskId)) return res.status(400).json({ error: 'Неверный ID задачи' }); + const task = await storage.getTask(taskId, req.organizationId!); + if (!task) return res.status(404).json({ error: 'Задача не найдена' }); + const acknowledged = await acknowledgeDelegationReview(taskId, req.user!.id, req.organizationId!); + if (!acknowledged) { + return res.status(404).json({ error: 'Задача не ожидает вашего ознакомления' }); + } + eventBus.publishEvent({ + type: 'task_updated', + organizationId: req.organizationId!, + data: { taskId, formId: task.formId, task } + }); + res.json({ success: true }); + } catch (err) { + console.error('Delegation ack error:', err); + res.status(500).json({ error: 'Ошибка при подтверждении ознакомления' }); + } + } + ); + // === TASK USER ACCESS === router.get('/api/tasks/:id/access', diff --git a/server/routes/user.routes.ts b/server/routes/user.routes.ts index 530e200..9a17931 100644 --- a/server/routes/user.routes.ts +++ b/server/routes/user.routes.ts @@ -8,6 +8,14 @@ import { validateRequest } from "../middleware/validation.middleware"; import { hashPassword } from "../utils/password"; import { logAudit, getClientIp } from "../utils/audit"; import { canManageUser, filterUsersByAccess, getManagedUserIds } from "../utils/user-access"; +import { + listDelegations, + getDelegation, + createDelegation, + updateDelegation, + deleteDelegation, + bulkAssignDelegate, +} from "../services/delegation.service"; import { db } from "../db"; import { roles, roleMembers, userStatuses, insertUserStatusSchema, updateUserStatusSchema } from "@shared/schema"; import { eq, and, inArray } from "drizzle-orm"; @@ -653,4 +661,220 @@ export function registerUserRoutes(router: ReturnType { + try { + const userId = Number(req.params.id); + const target = await storage.getUser(userId); + if (!target || target.organizationId !== req.organizationId) { + return res.status(404).json({ success: false, error: 'Пользователь не найден' }); + } + // Чтение доступно всем сотрудникам организации: коллеги видят бейдж + // «Замещает: …» в профиле (как в Pyrus). Мутации — только владельцу/админу. + const delegations = await listDelegations(userId, req.organizationId!); + res.json({ success: true, delegations }); + } catch (error) { + console.error('List delegations error:', error); + res.status(500).json({ success: false, error: 'Ошибка при получении замещений' }); + } + } + ); + + router.post('/api/users/:id(\\d+)/delegations', + validateTenantAccess, + async (req: AuthenticatedRequest, res) => { + try { + const userId = Number(req.params.id); + const { delegateUserId, formId, dateFrom, dateTo } = req.body ?? {}; + const target = await storage.getUser(userId); + if (!target || target.organizationId !== req.organizationId) { + return res.status(404).json({ success: false, error: 'Пользователь не найден' }); + } + const canEdit = await canManageUser(req.user, userId, req.organizationId!); + if (!canEdit) { + return res.status(403).json({ success: false, error: 'Недостаточно прав' }); + } + + const delegateId = Number(delegateUserId); + if (!Number.isFinite(delegateId)) { + return res.status(400).json({ success: false, error: 'Укажите заместителя' }); + } + if (delegateId === userId) { + return res.status(400).json({ success: false, error: 'Нельзя назначить заместителем самого себя' }); + } + const delegate = await storage.getUser(delegateId); + if (!delegate || delegate.organizationId !== req.organizationId || !delegate.isActive) { + return res.status(400).json({ success: false, error: 'Заместитель не найден или неактивен' }); + } + if (typeof dateFrom !== 'string' || !DATE_RE.test(dateFrom) || + typeof dateTo !== 'string' || !DATE_RE.test(dateTo)) { + return res.status(400).json({ success: false, error: 'Укажите даты в формате YYYY-MM-DD' }); + } + if (dateFrom > dateTo) { + return res.status(400).json({ success: false, error: 'Дата начала не может быть позже даты окончания' }); + } + let formIdVal: number | null = null; + if (formId !== undefined && formId !== null) { + formIdVal = Number(formId); + if (!Number.isFinite(formIdVal)) { + return res.status(400).json({ success: false, error: 'Некорректная форма' }); + } + const form = await storage.getForm(formIdVal, req.organizationId!); + if (!form) { + return res.status(400).json({ success: false, error: 'Форма не найдена' }); + } + } + + const result = await createDelegation({ + organizationId: req.organizationId!, + userId, + delegateUserId: delegateId, + formId: formIdVal, + dateFrom, + dateTo, + createdBy: req.user?.id ?? null, + }); + if (result.conflict) { + return res.status(409).json({ + success: false, + error: 'Замещение для этого заместителя с пересекающимся периодом уже существует', + }); + } + + logAudit({ + action: 'delegation.created', + userId: req.user?.id ?? null, + organizationId: req.organizationId!, + details: { + delegationId: result.delegation.id, + absentUserId: userId, + delegateUserId: delegateId, + formId: formIdVal, + dateFrom, + dateTo, + }, + ip: getClientIp(req), + }); + + // Массовое до-назначение заместителя в текущие задачи — асинхронно + bulkAssignDelegate(result.delegation.id).catch((err) => + console.error('[Delegation] bulkAssignDelegate after create error:', err) + ); + + res.json({ success: true, delegation: result.delegation }); + } catch (error) { + console.error('Create delegation error:', error); + res.status(500).json({ success: false, error: 'Ошибка при создании замещения' }); + } + } + ); + + router.patch('/api/users/:id(\\d+)/delegations/:delegationId(\\d+)', + validateTenantAccess, + async (req: AuthenticatedRequest, res) => { + try { + const userId = Number(req.params.id); + const delegationId = Number(req.params.delegationId); + const canEdit = await canManageUser(req.user, userId, req.organizationId!); + if (!canEdit) { + return res.status(403).json({ success: false, error: 'Недостаточно прав' }); + } + + const existing = await getDelegation(delegationId); + if (!existing || existing.userId !== userId || existing.organizationId !== req.organizationId) { + return res.status(404).json({ success: false, error: 'Замещение не найдено' }); + } + + const { isActive, dateFrom, dateTo } = req.body ?? {}; + const patch: { isActive?: boolean; dateFrom?: string; dateTo?: string } = {}; + if (isActive !== undefined) { + if (typeof isActive !== 'boolean') { + return res.status(400).json({ success: false, error: 'isActive должно быть boolean' }); + } + patch.isActive = isActive; + } + if (dateFrom !== undefined) { + if (typeof dateFrom !== 'string' || !DATE_RE.test(dateFrom)) { + return res.status(400).json({ success: false, error: 'dateFrom в формате YYYY-MM-DD' }); + } + patch.dateFrom = dateFrom; + } + if (dateTo !== undefined) { + if (typeof dateTo !== 'string' || !DATE_RE.test(dateTo)) { + return res.status(400).json({ success: false, error: 'dateTo в формате YYYY-MM-DD' }); + } + patch.dateTo = dateTo; + } + if (Object.keys(patch).length === 0) { + return res.status(400).json({ success: false, error: 'Нет полей для обновления' }); + } + + const newFrom = patch.dateFrom ?? existing.dateFrom; + const newTo = patch.dateTo ?? existing.dateTo; + if (newFrom > newTo) { + return res.status(400).json({ success: false, error: 'Дата начала не может быть позже даты окончания' }); + } + + const updated = await updateDelegation(delegationId, userId, req.organizationId!, patch); + if (!updated) { + return res.status(404).json({ success: false, error: 'Замещение не найдено' }); + } + + logAudit({ + action: 'delegation.updated', + userId: req.user?.id ?? null, + organizationId: req.organizationId!, + details: { delegationId, absentUserId: userId, patch }, + ip: getClientIp(req), + }); + + // При активации/продлении — до-назначить заместителя в текущие задачи + if (updated.isActive) { + bulkAssignDelegate(updated.id).catch((err) => + console.error('[Delegation] bulkAssignDelegate after update error:', err) + ); + } + + res.json({ success: true, delegation: updated }); + } catch (error) { + console.error('Update delegation error:', error); + res.status(500).json({ success: false, error: 'Ошибка при обновлении замещения' }); + } + } + ); + + router.delete('/api/users/:id(\\d+)/delegations/:delegationId(\\d+)', + validateTenantAccess, + async (req: AuthenticatedRequest, res) => { + try { + const userId = Number(req.params.id); + const delegationId = Number(req.params.delegationId); + const canEdit = await canManageUser(req.user, userId, req.organizationId!); + if (!canEdit) { + return res.status(403).json({ success: false, error: 'Недостаточно прав' }); + } + const deleted = await deleteDelegation(delegationId, userId, req.organizationId!); + if (!deleted) { + return res.status(404).json({ success: false, error: 'Замещение не найдено' }); + } + logAudit({ + action: 'delegation.deleted', + userId: req.user?.id ?? null, + organizationId: req.organizationId!, + details: { delegationId, absentUserId: userId }, + ip: getClientIp(req), + }); + res.json({ success: true }); + } catch (error) { + console.error('Delete delegation error:', error); + res.status(500).json({ success: false, error: 'Ошибка при удалении замещения' }); + } + } + ); } diff --git a/server/services/delegation.service.ts b/server/services/delegation.service.ts new file mode 100644 index 0000000..913d58c --- /dev/null +++ b/server/services/delegation.service.ts @@ -0,0 +1,485 @@ +import { db } from "../db"; +import { + userDelegations, + taskAssignees, + tasks, + users, + forms, + type UserDelegation, +} from "@shared/schema"; +import { eq, and, or, isNull, isNotNull, sql, desc } from "drizzle-orm"; +import { storage } from "../storage"; +import { notifyTaskAssigned } from "../utils/notifyAssignee"; +import { logAudit } from "../utils/audit"; + +/** + * Сервис замещения пользователей (delegation/substitution, референс — Pyrus). + * + * Модель: + * - user_delegations — кто кого замещает, на какой период и (опционально) по какой форме; + * form_id IS NULL — заместитель по умолчанию для всех форм. + * - При назначении пользователя U исполнителем задачи активный заместитель D + * добавляется в task_assignees рядом с U (строка с delegated_from_user_id = U). + * - Контур ознакомления: после пересчёта исполнителей при переходе отсутствующий U + * возвращается в task_assignees с pending_review = true, пока не подтвердит + * ознакомление (acknowledgeDelegationReview). + */ + +function formatUserName(u: { firstName: string | null; lastName: string | null; middleName?: string | null; email: string } | null | undefined): string { + if (!u) return ""; + return (`${u.firstName || ""} ${u.middleName || ""} ${u.lastName || ""}`.trim()) || u.email; +} + +function todayIso(): string { + return new Date().toISOString().slice(0, 10); +} + +/** + * Ищет активное на сегодня замещение пользователя. + * Приоритет: точное совпадение form_id, затем запись по умолчанию (form_id IS NULL). + */ +async function findActiveDelegation( + userId: number, + formId: number | null, + organizationId: number +): Promise { + const rows = await db + .select() + .from(userDelegations) + .where( + and( + eq(userDelegations.organizationId, organizationId), + eq(userDelegations.userId, userId), + eq(userDelegations.isActive, true), + sql`${userDelegations.dateFrom} <= CURRENT_DATE`, + sql`${userDelegations.dateTo} >= CURRENT_DATE`, + formId + ? or(eq(userDelegations.formId, formId), isNull(userDelegations.formId)) + : isNull(userDelegations.formId) + ) + ) + // Сначала точное совпадение формы (form_id IS NULL → false → первым) + .orderBy(sql`(${userDelegations.formId} IS NULL) ASC`, sql`${userDelegations.dateTo} ASC`) + .limit(1); + const delegation = rows[0]; + // Защита от самоназначения + if (!delegation || delegation.delegateUserId === userId) return null; + return delegation; +} + +/** + * Резолвит активного заместителя пользователя с учётом цепочки + * (1 дополнительный уровень: если у заместителя тоже есть замещение — + * берём его заместителя) и защитой от циклов через visited-set. + */ +export async function getActiveDelegate( + userId: number, + formId: number | null, + organizationId: number, + visited: Set = new Set() +): Promise { + if (visited.has(userId)) return null; + visited.add(userId); + const delegation = await findActiveDelegation(userId, formId, organizationId); + if (!delegation) return null; + const delegateId = delegation.delegateUserId; + if (delegateId === userId || visited.has(delegateId)) return null; + // Цепочка: один дополнительный уровень вложенности + const nested = await findActiveDelegation(delegateId, formId, organizationId); + if (nested && nested.delegateUserId !== delegateId && !visited.has(nested.delegateUserId)) { + return nested.delegateUserId; + } + return delegateId; +} + +/** + * Вызывается при назначении пользователя исполнителем задачи. + * Резолвит заместителя и добавляет его в task_assignees рядом с назначенным + * (tasks.assignedTo не меняется). Идемпотентно: дубликаты игнорируются. + */ +export async function applyDelegationsOnAssign( + taskId: number, + userId: number, + organizationId: number +): Promise { + try { + const task = await storage.getTask(taskId, organizationId); + if (!task) return; + const delegateId = await getActiveDelegate(userId, task.formId, organizationId); + if (!delegateId) return; + + const existing = await db + .select({ id: taskAssignees.id }) + .from(taskAssignees) + .where(and(eq(taskAssignees.taskId, taskId), eq(taskAssignees.userId, delegateId))) + .limit(1); + if (existing.length > 0) return; // заместитель уже исполнитель — ничего не делаем + + await storage.addTaskAssignee(taskId, delegateId, organizationId, { delegatedFromUserId: userId }); + + const [absentUser, delegateUser] = await Promise.all([ + storage.getUser(userId), + storage.getUser(delegateId), + ]); + const absentName = formatUserName(absentUser) || String(userId); + const delegateName = formatUserName(delegateUser) || String(delegateId); + + storage + .addTaskAuditLog({ + taskId, + organizationId, + action: "task.updated", + fieldName: "Ответственный добавлен", + oldValue: null, + newValue: `${delegateName} (замещение: ${absentName})`, + changedBy: null, + changedByName: "Замещение", + metadata: { delegation: true, delegatedFrom: userId, delegateUserId: delegateId }, + }) + .catch((e: unknown) => console.error("[Delegation] audit log error:", e)); + + notifyTaskAssigned(task, delegateId, null, organizationId, { delegatedFromName: absentName }).catch( + (err) => console.error("[Delegation] notifyTaskAssigned error:", err) + ); + } catch (err) { + console.error("[Delegation] applyDelegationsOnAssign error:", err); + } +} + +/** + * Контур ознакомления. Вызывается ПОСЛЕ пересчёта исполнителей при переходе. + * Находит отсутствующих исполнителей U с активным замещением, чей заместитель + * есть среди исполнителей задачи (строка delegated_from_user_id = U), и возвращает + * U в task_assignees с pending_review = true (если строки нет — создаёт). + */ +export async function ensureReviewAssignees(taskId: number, organizationId: number): Promise { + try { + const rows = await db + .select() + .from(taskAssignees) + .where(and(eq(taskAssignees.taskId, taskId), eq(taskAssignees.organizationId, organizationId))); + + const delegatedFromIds = [ + ...new Set( + rows.filter((r) => r.delegatedFromUserId != null).map((r) => r.delegatedFromUserId as number) + ), + ]; + if (delegatedFromIds.length === 0) return; + + const task = await storage.getTask(taskId, organizationId); + if (!task) return; + + const reviewNames: string[] = []; + for (const absentId of delegatedFromIds) { + // Возвращаем на ознакомление только пока замещение активно + const active = await findActiveDelegation(absentId, task.formId, organizationId); + if (!active) continue; + + const existingRow = rows.find((r) => r.userId === absentId); + if (existingRow) { + if (existingRow.pendingReview) continue; // уже на ознакомлении + await db + .update(taskAssignees) + .set({ pendingReview: true }) + .where(eq(taskAssignees.id, existingRow.id)); + } else { + await storage.addTaskAssignee(taskId, absentId, organizationId, { pendingReview: true }); + } + const u = await storage.getUser(absentId); + reviewNames.push(formatUserName(u) || String(absentId)); + } + + if (reviewNames.length > 0) { + storage + .addTaskAuditLog({ + taskId, + organizationId, + action: "task.updated", + fieldName: "Замещение: на ознакомление", + oldValue: null, + newValue: reviewNames.join(", "), + changedBy: null, + changedByName: "Замещение", + metadata: { delegation: true, pendingReview: true }, + }) + .catch((e: unknown) => console.error("[Delegation] audit log (review) error:", e)); + } + } catch (err) { + console.error("[Delegation] ensureReviewAssignees error:", err); + } +} + +/** + * Подтверждение ознакомления: удаляет строку task_assignees пользователя, + * если она помечена pending_review = true. Возвращает true, если строка была снята. + */ +export async function acknowledgeDelegationReview( + taskId: number, + userId: number, + organizationId: number +): Promise { + const rows = await db + .select() + .from(taskAssignees) + .where( + and( + eq(taskAssignees.taskId, taskId), + eq(taskAssignees.userId, userId), + eq(taskAssignees.organizationId, organizationId), + eq(taskAssignees.pendingReview, true) + ) + ) + .limit(1); + if (rows.length === 0) return false; + + await storage.removeTaskAssignee(taskId, userId, organizationId); + + const u = await storage.getUser(userId); + const name = formatUserName(u) || String(userId); + storage + .addTaskAuditLog({ + taskId, + organizationId, + action: "task.updated", + fieldName: "Замещение: ознакомление подтверждено", + oldValue: name, + newValue: null, + changedBy: userId, + changedByName: name, + metadata: { delegation: true }, + }) + .catch((e: unknown) => console.error("[Delegation] audit log (ack) error:", e)); + return true; +} + +/** + * Снимает строки заместителей, у которых замещение истекло/удалено/деактивировано + * (Pyrus: «при изменении задачи заместитель удаляется из маршрута»). + * Строки с delegated_from_user_id IS NULL (прямые назначения) не трогаем. + */ +export async function cleanupStaleDelegates(taskId: number, organizationId: number): Promise { + try { + const rows = await db + .select() + .from(taskAssignees) + .where( + and( + eq(taskAssignees.taskId, taskId), + eq(taskAssignees.organizationId, organizationId), + isNotNull(taskAssignees.delegatedFromUserId) + ) + ); + if (rows.length === 0) return; + + const task = await storage.getTask(taskId, organizationId); + if (!task) return; + + for (const row of rows) { + // Проверяем через резолвер — так корректно обрабатываются цепочки замещений + const activeDelegateId = await getActiveDelegate(row.delegatedFromUserId as number, task.formId, organizationId); + if (activeDelegateId !== row.userId) { + await db.delete(taskAssignees).where(eq(taskAssignees.id, row.id)); + } + } + } catch (err) { + console.error("[Delegation] cleanupStaleDelegates error:", err); + } +} + +// ===================== +// CRUD замещений (REST API) +// ===================== + +export async function listDelegations(userId: number, organizationId: number) { + return db + .select({ + id: userDelegations.id, + userId: userDelegations.userId, + delegateUserId: userDelegations.delegateUserId, + formId: userDelegations.formId, + dateFrom: userDelegations.dateFrom, + dateTo: userDelegations.dateTo, + isActive: userDelegations.isActive, + createdBy: userDelegations.createdBy, + createdAt: userDelegations.createdAt, + delegateName: sql`concat(${users.firstName}, ' ', ${users.lastName})`, + formName: forms.name, + }) + .from(userDelegations) + .leftJoin(users, eq(users.id, userDelegations.delegateUserId)) + .leftJoin(forms, eq(forms.id, userDelegations.formId)) + .where(and(eq(userDelegations.userId, userId), eq(userDelegations.organizationId, organizationId))) + .orderBy(desc(userDelegations.createdAt)); +} + +export async function getDelegation(delegationId: number): Promise { + const rows = await db.select().from(userDelegations).where(eq(userDelegations.id, delegationId)).limit(1); + return rows[0] ?? null; +} + +/** + * Создание замещения с проверкой пересечения периодов + * для той же пары (user_id, delegate_user_id, form_id). + */ +export async function createDelegation(data: { + organizationId: number; + userId: number; + delegateUserId: number; + formId: number | null; + dateFrom: string; + dateTo: string; + createdBy: number | null; +}): Promise<{ conflict: true } | { conflict: false; delegation: UserDelegation }> { + const overlaps = await db + .select({ id: userDelegations.id }) + .from(userDelegations) + .where( + and( + eq(userDelegations.organizationId, data.organizationId), + eq(userDelegations.userId, data.userId), + eq(userDelegations.delegateUserId, data.delegateUserId), + data.formId ? eq(userDelegations.formId, data.formId) : isNull(userDelegations.formId), + eq(userDelegations.isActive, true), + sql`${userDelegations.dateFrom} <= ${data.dateTo}::date`, + sql`${userDelegations.dateTo} >= ${data.dateFrom}::date` + ) + ) + .limit(1); + if (overlaps.length > 0) return { conflict: true }; + + const [delegation] = await db + .insert(userDelegations) + .values({ + organizationId: data.organizationId, + userId: data.userId, + delegateUserId: data.delegateUserId, + formId: data.formId, + dateFrom: data.dateFrom, + dateTo: data.dateTo, + isActive: true, + createdBy: data.createdBy, + }) + .returning(); + return { conflict: false, delegation }; +} + +export async function updateDelegation( + delegationId: number, + userId: number, + organizationId: number, + patch: { isActive?: boolean; dateFrom?: string; dateTo?: string } +): Promise { + const [updated] = await db + .update(userDelegations) + .set(patch) + .where( + and( + eq(userDelegations.id, delegationId), + eq(userDelegations.userId, userId), + eq(userDelegations.organizationId, organizationId) + ) + ) + .returning(); + return updated ?? null; +} + +export async function deleteDelegation( + delegationId: number, + userId: number, + organizationId: number +): Promise { + const deleted = await db + .delete(userDelegations) + .where( + and( + eq(userDelegations.id, delegationId), + eq(userDelegations.userId, userId), + eq(userDelegations.organizationId, organizationId) + ) + ) + .returning({ id: userDelegations.id }); + return deleted.length > 0; +} + +/** + * Массовое до-назначение: заместитель D добавляется во все незавершённые задачи, + * где отсутствующий U — исполнитель (assigned_to или task_assignees). + * Если form_id записи NULL — все формы, иначе только эта форма. + * Обработка чанками по 50 с паузой, чтобы не перегружать БД. + */ +export async function bulkAssignDelegate(delegationId: number): Promise { + try { + const delegation = await getDelegation(delegationId); + if (!delegation) return; + + // До-назначаем только актуальное замещение (активно и сегодня внутри периода) + const today = todayIso(); + if (!delegation.isActive || delegation.dateFrom > today || delegation.dateTo < today) return; + + const tasksToUpdate = await db + .select({ id: tasks.id, title: tasks.title, formId: tasks.formId }) + .from(tasks) + .where( + and( + eq(tasks.organizationId, delegation.organizationId), + eq(tasks.isCompleted, false), + delegation.formId ? eq(tasks.formId, delegation.formId) : undefined, + sql`(${tasks.assignedTo} = ${delegation.userId} OR EXISTS ( + SELECT 1 FROM task_assignees ta + WHERE ta.task_id = ${tasks.id} AND ta.user_id = ${delegation.userId} + ))` + ) + ); + + if (tasksToUpdate.length === 0) return; + + const absentUser = await storage.getUser(delegation.userId); + const absentName = formatUserName(absentUser) || String(delegation.userId); + + const CHUNK_SIZE = 50; + let affected = 0; + for (let i = 0; i < tasksToUpdate.length; i += CHUNK_SIZE) { + const chunk = tasksToUpdate.slice(i, i + CHUNK_SIZE); + for (const t of chunk) { + const existing = await db + .select({ id: taskAssignees.id }) + .from(taskAssignees) + .where(and(eq(taskAssignees.taskId, t.id), eq(taskAssignees.userId, delegation.delegateUserId))) + .limit(1); + if (existing.length > 0) continue; + await storage + .addTaskAssignee(t.id, delegation.delegateUserId, delegation.organizationId, { + delegatedFromUserId: delegation.userId, + }) + .catch(() => {}); + affected++; + notifyTaskAssigned( + { id: t.id, title: t.title, formId: t.formId }, + delegation.delegateUserId, + null, + delegation.organizationId, + { delegatedFromName: absentName } + ).catch((err) => console.error("[Delegation] bulk notify error:", err)); + } + // Пауза между чанками — не перегружаем БД и шину уведомлений + if (i + CHUNK_SIZE < tasksToUpdate.length) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + logAudit({ + action: "delegation.bulk_assign", + organizationId: delegation.organizationId, + userId: delegation.userId, + details: { + delegationId, + delegateUserId: delegation.delegateUserId, + formId: delegation.formId, + tasksScanned: tasksToUpdate.length, + tasksAffected: affected, + }, + }); + } catch (err) { + console.error("[Delegation] bulkAssignDelegate error:", err); + } +} diff --git a/server/services/task-transition.service.ts b/server/services/task-transition.service.ts index 34b987e..dcc654f 100644 --- a/server/services/task-transition.service.ts +++ b/server/services/task-transition.service.ts @@ -6,6 +6,7 @@ import { tasksMinimalCache } from "../utils/cache"; import { indexTaskAsync } from "../routes/task-helpers"; import { fieldConditionsMatch, resolveFieldDisplayText, isFieldValueEmpty, SYSTEM_TASK_FIELDS, buildSystemFieldValuesForConditions } from "../utils/field-conditions"; import { validateRequiredFieldsDetailed } from "../utils/validate-required-fields"; +import { ensureReviewAssignees, cleanupStaleDelegates } from "./delegation.service"; export interface ExecuteTaskTransitionOptions { taskId: number; @@ -426,6 +427,11 @@ export async function executeTaskTransition(options: ExecuteTaskTransitionOption }); } + // Замещение: вернуть отсутствующих исполнителей «на ознакомление» + // и снять заместителей с истёкшим замещением + await ensureReviewAssignees(taskId, organizationId); + await cleanupStaleDelegates(taskId, organizationId); + // If a role was resolved as assignee — assign it to the task if (resolvedRoleId !== null) { await storage.addTaskRole(taskId, resolvedRoleId, organizationId).catch(err => { diff --git a/server/storage/system.storage.ts b/server/storage/system.storage.ts index 9e097c8..fba223b 100644 --- a/server/storage/system.storage.ts +++ b/server/storage/system.storage.ts @@ -48,12 +48,41 @@ export class SystemStorage extends RolesStorage { )); } - async addTaskAssignee(taskId: number, userId: number, organizationId: number): Promise { + async addTaskAssignee( + taskId: number, + userId: number, + organizationId: number, + options?: { delegatedFromUserId?: number | null; pendingReview?: boolean } + ): Promise { const existing = await db.select().from(taskAssignees) .where(and(eq(taskAssignees.taskId, taskId), eq(taskAssignees.userId, userId))) .limit(1); - if (existing.length > 0) return existing[0]; - const [row] = await db.insert(taskAssignees).values({ taskId, userId, organizationId }).returning(); + if (existing.length > 0) { + // Явный запрос «на ознакомление» — проставляем флаг на существующей строке + if (options?.pendingReview && !existing[0].pendingReview) { + const [updated] = await db.update(taskAssignees) + .set({ pendingReview: true }) + .where(eq(taskAssignees.id, existing[0].id)) + .returning(); + return updated; + } + return existing[0]; + } + const [row] = await db.insert(taskAssignees).values({ + taskId, + userId, + organizationId, + delegatedFromUserId: options?.delegatedFromUserId ?? null, + pendingReview: options?.pendingReview ?? false, + }).returning(); + // Хук замещения: прямое назначение резолвит активного заместителя. + // Пропускаем строки заместителей/ознакомления — иначе получили бы рекурсию. + // Динамический import разрывает цикл storage → delegation.service → storage. + if (!options?.delegatedFromUserId && !options?.pendingReview) { + import("../services/delegation.service") + .then((m) => m.applyDelegationsOnAssign(taskId, userId, organizationId)) + .catch((err) => console.error("[Delegation] applyDelegationsOnAssign hook error:", err)); + } return row; } diff --git a/server/storage/tasks.storage.ts b/server/storage/tasks.storage.ts index fed418b..bbc2ece 100644 --- a/server/storage/tasks.storage.ts +++ b/server/storage/tasks.storage.ts @@ -598,6 +598,8 @@ export class TasksStorage extends TasksCoreStorage { taskId: taskAssignees.taskId, userId: taskAssignees.userId, organizationId: taskAssignees.organizationId, + delegatedFromUserId: taskAssignees.delegatedFromUserId, + pendingReview: taskAssignees.pendingReview, createdAt: taskAssignees.createdAt, user: { id: users.id, diff --git a/server/utils/auto-transitions.ts b/server/utils/auto-transitions.ts index e6a5edc..4eab112 100644 --- a/server/utils/auto-transitions.ts +++ b/server/utils/auto-transitions.ts @@ -16,6 +16,7 @@ import { indexTaskAsync } from "../routes/task-helpers"; import { webhookService } from "../services/webhook.service"; import { pushTaskUpdated } from "../utils/pushTaskUpdated"; import { notifyTaskAssigned } from "../utils/notifyAssignee"; +import { ensureReviewAssignees, cleanupStaleDelegates } from "../services/delegation.service"; export interface EvaluateAutoTransitionsOptions { triggeredBy?: number | null; @@ -277,6 +278,11 @@ async function applyAutoTransition( await storage.addTaskAssignee(task.id, targetAssigneeId, organizationId).catch(() => {}); } + // Замещение: вернуть отсутствующих исполнителей «на ознакомление» + // и снять заместителей с истёкшим замещением + await ensureReviewAssignees(task.id, organizationId); + await cleanupStaleDelegates(task.id, organizationId); + // Audit log — автопереход записывается от имени системы storage .addTaskAuditLog({ diff --git a/server/utils/notifyAssignee.ts b/server/utils/notifyAssignee.ts index b98b83b..084237b 100644 --- a/server/utils/notifyAssignee.ts +++ b/server/utils/notifyAssignee.ts @@ -17,8 +17,10 @@ export async function notifyTaskAssigned( task: { id: number; title: string; formId: number }, assigneeUserId: number, triggeredBy: number | null, - organizationId: number + organizationId: number, + options?: { delegatedFromName?: string } ): Promise { + const delegationSuffix = options?.delegatedFromName ? ` (вы замещаете ${options.delegatedFromName})` : ""; const eventBase = { type: EVENT_TYPES.TASK_ASSIGNED, organizationId, @@ -29,7 +31,10 @@ export async function notifyTaskAssigned( taskTitle: task.title, assignedTo: assigneeUserId, authorName: triggeredBy ? undefined : "Система", - message: `Вас назначили ответственным по задаче "${task.title}"`, + message: `Вас назначили ответственным по задаче "${task.title}"${delegationSuffix}`, + ...(options?.delegatedFromName + ? { delegation: true, delegatedFromName: options.delegatedFromName } + : {}), }, timestamp: new Date(), }; diff --git a/shared/schema.ts b/shared/schema.ts index 5ab18c7..151e05f 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -131,6 +131,27 @@ export const userStatuses = pgTable("user_statuses", { export type UserStatus = typeof userStatuses.$inferSelect; export type InsertUserStatus = typeof userStatuses.$inferInsert; +// Замещение пользователей (delegation): кто кого замещает на период. +// formId IS NULL — заместитель по умолчанию для всех форм организации. +export const userDelegations = pgTable("user_delegations", { + id: serial("id").primaryKey(), + organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), // кто отсутствует + delegateUserId: integer("delegate_user_id").notNull().references(() => users.id, { onDelete: "cascade" }), // заместитель + formId: integer("form_id").references(() => forms.id, { onDelete: "cascade" }), // NULL = по умолчанию + dateFrom: date("date_from", { mode: "string" }).notNull(), + dateTo: date("date_to", { mode: "string" }).notNull(), + isActive: boolean("is_active").notNull().default(true), + createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }), + createdAt: timestamp("created_at").defaultNow(), +}, (table) => ({ + userActiveIdx: index("user_delegations_user_active_idx").on(table.userId, table.isActive), + orgIdx: index("user_delegations_org_idx").on(table.organizationId), +})); + +export type UserDelegation = typeof userDelegations.$inferSelect; +export type InsertUserDelegation = typeof userDelegations.$inferInsert; + // User sessions table export const userSessions = pgTable("user_sessions", { id: serial("id").primaryKey(), @@ -3066,6 +3087,10 @@ export const taskAssignees = pgTable("task_assignees", { taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + // Замещение: кого замещает этот исполнитель (NULL — прямое назначение) + delegatedFromUserId: integer("delegated_from_user_id").references(() => users.id, { onDelete: "cascade" }), + // Отсутствующий исполнитель возвращён в задачу «на ознакомление» после перехода + pendingReview: boolean("pending_review").notNull().default(false), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ uniqueTaskAssignee: unique("task_assignees_task_user_key").on(table.taskId, table.userId),