import { Router } from "express"; import { storage } from "../storage"; import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; import { type Task, type FormStatus, type SafeUser, roles, tasks } from "@shared/schema"; import { db } from "../db"; import { eq, inArray } from "drizzle-orm"; import { tasksMinimalCache } from "../utils/cache"; import { clampTasksLimit, parseTasksCursor, buildTasksNextCursor } from "../utils/task-pagination"; import { clampDirectoryRowsLimit } from "../utils/data-table-rows-query"; // Лимиты выдачи уникальных значений в /column-values (автокомплит фильтров) const COLUMN_VALUES_DEFAULT_LIMIT = 20; const COLUMN_VALUES_MAX_LIMIT = 1000; function safeParseOptions(opts: unknown): unknown { if (Array.isArray(opts)) return opts; if (typeof opts === 'string') { try { return JSON.parse(opts); } catch { return undefined; } } if (typeof opts === 'object' && opts !== null) return opts; return undefined; } /** Сканирует fieldValues задач на поля типа 'task', собирает referenced task IDs и возвращает их titles */ async function buildRelatedTaskTitles( tasksList: Array<{ fieldValues?: { [fieldId: number]: unknown } } | any>, formFields: Array<{ id: number; type: string }>, ): Promise> { const taskFieldIds = new Set(); for (const f of formFields) { if (f.type === 'task') taskFieldIds.add(f.id); } if (taskFieldIds.size === 0) return {}; const referencedIds = new Set(); for (const t of tasksList) { if (!t.fieldValues) continue; for (const fid of taskFieldIds) { const val = t.fieldValues[fid]; if (val == null) continue; let taskId: number | undefined; if (typeof val === 'number') taskId = val; else if (typeof val === 'string') { const n = Number(val); if (!isNaN(n)) { taskId = n; } else { try { const parsed = JSON.parse(val); if (parsed && typeof parsed.taskId === 'number') taskId = parsed.taskId; else if (parsed && typeof parsed.id === 'number') taskId = parsed.id; } catch { /* ignore */ } } } else if (typeof val === 'object' && val !== null) { const obj = val as any; if (typeof obj.taskId === 'number') taskId = obj.taskId; else if (typeof obj.id === 'number') taskId = obj.id; } if (taskId !== undefined) referencedIds.add(taskId); } } if (referencedIds.size === 0) return {}; const relatedTasks = await db .select({ id: tasks.id, title: tasks.title }) .from(tasks) .where(inArray(tasks.id, Array.from(referencedIds))); const map: Record = {}; for (const t of relatedTasks) { if (t.id) map[t.id] = t.title || `#${t.id}`; } return map; } export function registerTaskCrudListRoutes(router: ReturnType): void { router.get('/api/tasks', async (req: AuthenticatedRequest, res) => { try { const requestedLimit = req.query.limit ? parseInt(req.query.limit as string) : undefined; // Дефолт 50, жёсткий кап 200 — без limit больше не уходит до 10000 полных строк const effectiveLimit = clampTasksLimit(requestedLimit); const sortBy = (req.query.sortBy as 'updatedAt' | 'createdAt') || 'updatedAt'; const order = (req.query.order as 'asc' | 'desc') || 'desc'; const statusId = req.query.statusId ? parseInt(req.query.statusId as string) : undefined; const assignedTo = req.query.assignedTo ? parseInt(req.query.assignedTo as string) : undefined; const minimal = req.query.minimal === 'true'; const search = typeof req.query.search === 'string' ? req.query.search.trim() : ''; // Курсорная пагинация: cursor = "_" (невалидный — игнорируется) const cursor = parseTasksCursor(typeof req.query.cursor === 'string' ? req.query.cursor : null); const appRole = req.user!.appRole; const accessibleIds = await storage.getAccessibleTaskIds(req.user!.id, req.organizationId!, appRole); if (search) { const searchLimit = requestedLimit && requestedLimit > 0 ? Math.min(requestedLimit, 100) : 50; let tasks = await storage.searchTasksByKeyword(req.organizationId!, search, searchLimit); if (accessibleIds !== null) { tasks = tasks.filter((t: any) => accessibleIds.has(t.id)); } return res.json({ success: true, tasks }); } // nextCursor выдаём только для дефолтной сортировки (updatedAt DESC) — режима курсора const computeNextCursor = (list: Array<{ id: number; updatedAt?: Date | string | null }>): string | null => { if (sortBy !== 'updatedAt' || order !== 'desc' || list.length < effectiveLimit) return null; const last = list[list.length - 1]; return last && last.updatedAt ? buildTasksNextCursor({ id: last.id, updatedAt: last.updatedAt }) : null; }; const cacheKey = (minimal && accessibleIds === null) ? `tasks:${req.organizationId}:minimal:${effectiveLimit}:${sortBy}:${order}:${statusId ?? ''}:${assignedTo ?? ''}:${cursor ? buildTasksNextCursor(cursor) ?? '' : ''}` : null; if (cacheKey) { const cached = tasksMinimalCache.get(cacheKey); if (cached) { return res.json({ success: true, tasks: cached, nextCursor: computeNextCursor(cached) }); } } let tasks = await storage.getTasksByOrganization(req.organizationId!, { limit: effectiveLimit, sortBy, order, statusId: statusId && !isNaN(statusId) ? statusId : undefined, assignedTo: assignedTo && !isNaN(assignedTo) ? assignedTo : undefined, minimal, // Списковый ответ без description: тяжёлая text-колонка в реестрах не нужна. // Поиск (выше) и деталь задачи (/api/tasks/:id) description сохраняют. excludeDescription: true, cursor, }); if (accessibleIds !== null) { tasks = (tasks as Task[]).filter((t: Task) => accessibleIds.has(t.id)); } if (cacheKey) { tasksMinimalCache.set(cacheKey, tasks); } res.json({ success: true, tasks, nextCursor: computeNextCursor(tasks as Array<{ id: number; updatedAt?: Date | string | null }>) }); } catch (error) { console.error('Get all tasks error:', error); res.status(500).json({ success: false, error: 'Ошибка при получении задач' }); } }); router.get('/api/forms/:id/tasks', async (req: AuthenticatedRequest, res) => { try { const formId = parseInt(req.params.id); if (isNaN(formId)) { return res.status(400).json({ success: false, error: 'Неверный ID формы' }); } const existingForm = await storage.getForm(formId, req.organizationId!); if (!existingForm) { return res.status(404).json({ success: false, error: 'Форма не найдена' }); } const appRole = req.user!.appRole; const [tasks, accessibleIds] = await Promise.all([ storage.getTasksWithFieldsByForm(formId, req.organizationId!), storage.getAccessibleTaskIds(req.user!.id, req.organizationId!, appRole), ]); const filteredTasks = accessibleIds !== null ? tasks.filter((t: Task) => accessibleIds.has(t.id)) : tasks; const formFields = await storage.getFormFields(formId, req.organizationId!); const relatedTaskTitles = await buildRelatedTaskTitles(filteredTasks, formFields); res.json({ success: true, tasks: filteredTasks, relatedTaskTitles }); } catch (error) { console.error('Get tasks error:', error); res.status(500).json({ success: false, error: 'Ошибка при получении задач' }); } }); router.get('/api/forms/:id/tasks/with-fields', async (req: AuthenticatedRequest, res) => { try { const formId = parseInt(req.params.id); if (isNaN(formId)) { return res.status(400).json({ success: false, error: 'Неверный ID формы' }); } const limit = req.query.limit ? parseInt(req.query.limit as string) : undefined; const offset = req.query.offset ? parseInt(req.query.offset as string) : 0; const existingForm = await storage.getForm(formId, req.organizationId!); if (!existingForm) { return res.status(404).json({ success: false, error: 'Форма не найдена' }); } const appRole = req.user!.appRole; const [result, accessibleIds] = await Promise.all([ storage.getTasksWithFieldsByFormOptimized(formId, req.organizationId!, { limit, offset }), storage.getAccessibleTaskIds(req.user!.id, req.organizationId!, appRole), ]); let tasks = result.tasks as Task[]; if (accessibleIds !== null) { tasks = tasks.filter((t: Task) => accessibleIds.has(t.id)); } // Build taskTitleMap early for filtering/sorting and field resolution const taskTitleMap = new Map(); for (const t of tasks) { if (t.id) taskTitleMap.set(t.id, t.title); } const filtersStr = req.query.filters as string; if (filtersStr) { try { const filters: Record = JSON.parse(filtersStr); if (Object.keys(filters).length > 0) { const [statuses, users, formFields, orgRoles] = await Promise.all([ storage.getFormStatuses(formId, req.organizationId!), storage.getUsersByOrganization(req.organizationId!), storage.getFormFields(formId, req.organizationId!), db.select().from(roles).where(eq(roles.organizationId, req.organizationId!)), ]); const statusMap = new Map(statuses.map((s: FormStatus) => [s.id, s])); const userMap = new Map(users.map((u: SafeUser) => [u.id, u])); const roleMap = new Map(orgRoles.map((r) => [r.id, r])); const contractNumberFields = formFields.filter((f) => f.type === 'contract-number' && (f as any).options?.contractNumber?.legalEntityFormId); const legalEntityMap = new Map(); for (const f of contractNumberFields) { const leFormId = (f as any).options.contractNumber.legalEntityFormId as number; try { const leTasks = await storage.getTasksWithFieldsByForm(leFormId, req.organizationId!); for (const t of leTasks) { legalEntityMap.set(t.id, t.title); } } catch {} } Object.entries(filters).forEach(([col, val]) => { if (!val) return; if (col === 'overdue' && val === 'true') { const today = new Date(); today.setHours(0, 0, 0, 0); tasks = tasks.filter((t: Task) => { const isCompleted = t.isCompleted || statusMap.get(t.currentStatusId)?.isFinal; return !isCompleted && t.dueDate && new Date(t.dueDate) < today; }); return; } const valLower = val.toLowerCase(); tasks = tasks.filter((t: Task) => { const tv = getTaskColumnValue(t, col, statusMap, userMap, formFields, roleMap, undefined, legalEntityMap, taskTitleMap); return tv.toLowerCase().includes(valLower); }); }); } } catch {} } const sortColumn = req.query.sortColumn as string; const sortDir = req.query.sortDirection as string; if (sortColumn && (sortDir === 'asc' || sortDir === 'desc')) { const [statuses, users, formFields, orgRoles] = await Promise.all([ storage.getFormStatuses(formId, req.organizationId!), storage.getUsersByOrganization(req.organizationId!), storage.getFormFields(formId, req.organizationId!), db.select().from(roles).where(eq(roles.organizationId, req.organizationId!)), ]); const statusMap = new Map(statuses.map((s: FormStatus) => [s.id, s])); const userMap = new Map(users.map((u: SafeUser) => [u.id, u])); const roleMap = new Map(orgRoles.map((r) => [r.id, r])); const contractNumberFields = formFields.filter((f) => f.type === 'contract-number' && (f as any).options?.contractNumber?.legalEntityFormId); const legalEntityMap = new Map(); for (const f of contractNumberFields) { const leFormId = (f as any).options.contractNumber.legalEntityFormId as number; try { const leTasks = await storage.getTasksWithFieldsByForm(leFormId, req.organizationId!); for (const t of leTasks) { legalEntityMap.set(t.id, t.title); } } catch {} } const DATE_COLS = new Set(['createdAt', 'updatedAt', 'dueDate', 'completedAt']); // Числовые и датовые кастомные поля сортируем по сырому значению, // а не по отображаемой строке (иначе «01.12.2025» < «02.01.2024» и «10» < «9») type TaskWithFV = Task & { fieldValues?: Record }; let numericFieldId: number | null = null; let numericFieldIsDate = false; if (sortColumn.startsWith('field_')) { const fieldId = parseInt(sortColumn.replace('field_', '')); const field = formFields.find((f) => f.id === fieldId); if (field && ['date', 'datetime', 'number'].includes(field.type)) { numericFieldId = fieldId; numericFieldIsDate = field.type === 'date' || field.type === 'datetime'; } } const toSortNumber = (t: TaskWithFV): number | null => { const v = t.fieldValues?.[numericFieldId!]; if (v === undefined || v === null || v === '') return null; if (numericFieldIsDate) { const ts = new Date(String(v)).getTime(); return isNaN(ts) ? null : ts; } const n = Number(String(v).replace(/\s/g, '')); return isNaN(n) ? null : n; }; tasks = [...tasks].sort((a, b) => { if (DATE_COLS.has(sortColumn)) { const _a = (a as Record)[sortColumn]; const _b = (b as Record)[sortColumn]; const av = _a ? new Date(String(_a)).getTime() : 0; const bv = _b ? new Date(String(_b)).getTime() : 0; return sortDir === 'asc' ? av - bv : bv - av; } if (numericFieldId !== null) { const av = toSortNumber(a as TaskWithFV); const bv = toSortNumber(b as TaskWithFV); // Пустые значения всегда в конец независимо от направления if (av === null && bv === null) return 0; if (av === null) return 1; if (bv === null) return -1; return sortDir === 'asc' ? av - bv : bv - av; } const av = getTaskColumnValue(a, sortColumn, statusMap, userMap, formFields, roleMap, undefined, legalEntityMap, taskTitleMap).toLowerCase(); const bv = getTaskColumnValue(b, sortColumn, statusMap, userMap, formFields, roleMap, undefined, legalEntityMap, taskTitleMap).toLowerCase(); const cmp = av.localeCompare(bv, 'ru'); return sortDir === 'asc' ? cmp : -cmp; }); } const formFields = await storage.getFormFields(formId, req.organizationId!); const relatedTaskTitles = await buildRelatedTaskTitles(tasks, formFields); res.json({ success: true, tasks, total: tasks.length, limit, offset, relatedTaskTitles }); } catch (error) { console.error('Get tasks with fields error:', error); res.status(500).json({ success: false, error: 'Ошибка при получении задач' }); } }); router.get('/api/forms/:id/tasks/status-counts', async (req: AuthenticatedRequest, res) => { try { const formId = parseInt(req.params.id); if (isNaN(formId)) { return res.status(400).json({ success: false, error: 'Неверный ID формы' }); } const existingForm = await storage.getForm(formId, req.organizationId!); if (!existingForm) { return res.status(404).json({ success: false, error: 'Форма не найдена' }); } const data = await storage.getTaskCountsByStatus(formId, req.organizationId!); res.json({ success: true, data }); } catch (error) { console.error('Get status counts error:', error); res.status(500).json({ success: false, error: 'Ошибка при получении количества задач по статусам' }); } }); // Отображаемое имя одного элемента user-поля (`user:` / `role:` / legacy-число) function resolveUserColumnEntry( entry: string, userMap: Map, roleMap?: Map ): string { if (entry.startsWith('user:')) { const uid = Number(entry.replace('user:', '')); const u = userMap.get(uid); return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : entry; } if (entry.startsWith('role:')) { const rid = Number(entry.replace('role:', '')); const r = roleMap?.get(rid); return r?.name ? `Роль: ${r.name}` : entry; } const uid = Number(entry); if (!isNaN(uid)) { const u = userMap.get(uid); return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : entry; } return entry; } function getTaskColumnValue( task: Task & { fieldValues?: Record; assignees?: Array<{ userId: number; user?: { firstName: string | null; middleName: string | null; lastName: string | null } }> }, column: string, statusMap: Map, userMap: Map, fields?: Array<{ id: number; type: string; options?: unknown }>, roleMap?: Map, tableRowMap?: Map>, legalEntityMap?: Map, taskTitleMap?: Map ): string { if (column === 'title') return task.title || ''; if (column === 'status') { const s = statusMap.get(task.currentStatusId); return s ? s.name : ''; } if (column === 'currentStatusId') { return String(task.currentStatusId || ''); } if (column === 'assignedTo') { const taskAny = task as Task & { assignees?: Array<{ userId: number; user?: { firstName: string | null; middleName: string | null; lastName: string | null } }> }; if (taskAny.assignees && Array.isArray(taskAny.assignees) && taskAny.assignees.length > 0) { return taskAny.assignees .map((a: { userId: number; user?: { firstName: string | null; middleName: string | null; lastName: string | null } }) => { const u = a.user ?? userMap.get(a.userId); return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : ''; }) .filter(Boolean) .join(', '); } if (!task.assignedTo) return ''; const u = userMap.get(task.assignedTo); return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : ''; } if (column === 'createdBy') { if (!task.createdBy) return ''; const u = userMap.get(task.createdBy); return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : ''; } if (column === 'dueDate') { return task.dueDate ? new Date(task.dueDate).toLocaleDateString('ru-RU') : ''; } if (column === 'createdAt') { return task.createdAt ? new Date(task.createdAt).toLocaleDateString('ru-RU') : ''; } if (column === 'updatedAt') { return task.updatedAt ? new Date(task.updatedAt).toLocaleDateString('ru-RU') : ''; } if (column === 'completedAt') { return task.completedAt ? new Date(task.completedAt).toLocaleDateString('ru-RU') : ''; } if (column.startsWith('field_')) { const fieldId = parseInt(column.replace('field_', '')); const field = fields?.find(f => f.id === fieldId); const fv = task.fieldValues; if (fv && typeof fv === 'object') { const v = fv[fieldId]; if (v !== undefined && v !== null) { const str = String(v); switch (field?.type) { case 'user': { // Мультивыбор (allow_multiple): массив ["user:5", "role:2"] — имена через запятую let parsedUserVal: unknown = v; if (typeof v === 'string' && v.startsWith('[')) { try { parsedUserVal = JSON.parse(v); } catch { parsedUserVal = v; } } if (Array.isArray(parsedUserVal)) { return parsedUserVal .map((entry) => resolveUserColumnEntry(String(entry), userMap, roleMap)) .filter(Boolean) .join(', '); } return resolveUserColumnEntry(str, userMap, roleMap); } case 'role': { if (str.startsWith('role:')) { const rid = Number(str.replace('role:', '')); const r = roleMap?.get(rid); return r?.name || str; } const rid = Number(str); if (!isNaN(rid)) { const r = roleMap?.get(rid); return r?.name || str; } return str; } case 'contract-number': { try { const parsed = typeof v === 'string' ? JSON.parse(v) : v; const num = parsed?.value ? String(parsed.value) : str; const entityTitle = parsed?.legalEntityTaskId ? legalEntityMap?.get(Number(parsed.legalEntityTaskId)) : undefined; const result = entityTitle ? `${num} — ${entityTitle}` : num; return result; } catch { return str; } } case 'company': { try { const parsed = typeof v === 'string' ? JSON.parse(v) : v; return parsed?.name || parsed?.shortName || parsed?.fullName || str; } catch { return str; } } case 'select': case 'radio-group': { const parsedOpts = safeParseOptions(field.options); const opts = Array.isArray(parsedOpts) ? parsedOpts as Array<{ label?: string; value?: string; id?: string }> : []; const opt = opts.find(o => String(o.value ?? o.id) === str); return opt?.label || str; } case 'table': { const parsedOpts = safeParseOptions(field.options); const tableOpts = (typeof parsedOpts === 'object' && parsedOpts !== null && !Array.isArray(parsedOpts)) ? parsedOpts as { tableId?: number; displayColumn?: string } : undefined; const tableId = tableOpts?.tableId; // Значение может быть числом/строкой или JSON-объектом {rowId: n} let rowId = Number(str); if (isNaN(rowId) && typeof v === 'object' && v !== null) { try { const obj = typeof v === 'string' ? JSON.parse(v) : v; if (obj && typeof obj.rowId === 'number') { rowId = obj.rowId; } } catch { /* ignore */ } } if (tableId && !isNaN(rowId) && tableRowMap?.has(tableId)) { const displayValue = tableRowMap.get(tableId)!.get(rowId); if (displayValue !== undefined) return displayValue; } return str; } case 'toggle': case 'checkbox': { if (v === true || str === 'true') return 'Да'; if (v === false || str === 'false') return 'Нет'; return str; } case 'checklist': { if (Array.isArray(v)) { const checkedCount = v.filter((it: any) => it?.checked).length; const totalCount = Array.isArray(field.options) ? field.options.length : 0; return `Выполнено ${checkedCount} из ${totalCount}`; } return str; } case 'file': { if (Array.isArray(v)) { return v .map(f => typeof f === 'object' && f !== null ? (f as { name?: string }).name || '' : String(f)) .filter(Boolean) .join(', '); } if (typeof v === 'object' && v !== null) { return (v as { name?: string }).name || str; } return str; } case 'geo': { try { const parsed = typeof v === 'string' ? JSON.parse(v) : v; return parsed?.address || `${parsed?.lat || ''}, ${parsed?.lng || ''}` || str; } catch { return str; } } case 'date': case 'datetime': { const dateValue = typeof v === 'string' ? v : str; if (!dateValue) return ''; const date = new Date(dateValue); if (!isNaN(date.getTime())) { return date.toLocaleDateString('ru-RU'); } return dateValue; } case 'task': { const taskId = typeof v === 'number' ? v : Number(str); if (!isNaN(taskId)) { const title = taskTitleMap?.get(taskId); if (title) return title; return `#${taskId}`; } return str; } } // Fallback для file-like объектов if (Array.isArray(v)) { return v .filter((f: unknown): f is { name: string } => typeof f === 'object' && f !== null && typeof (f as { name?: unknown }).name === 'string') .map((f: { name: string }) => f.name) .join(', '); } if (typeof v === 'object' && v !== null && 'name' in v && typeof (v as Record).name === 'string') { return (v as Record).name; } return String(v); } } return ''; } return ''; } router.get('/api/forms/:id/tasks/column-values', async (req: AuthenticatedRequest, res) => { try { const formId = parseInt(req.params.id); if (isNaN(formId)) return res.status(400).json({ error: 'Неверный ID формы' }); const column = (req.query.column as string) || ''; const search = ((req.query.search as string) || '').toLowerCase(); const contextStr = req.query.context as string; const existingForm = await storage.getForm(formId, req.organizationId!); if (!existingForm) return res.status(404).json({ error: 'Форма не найдена' }); const appRole = req.user!.appRole; const [result, accessibleIds] = await Promise.all([ storage.getTasksWithFieldsByFormOptimized(formId, req.organizationId!), storage.getAccessibleTaskIds(req.user!.id, req.organizationId!, appRole), ]); type TaskWithFieldValues = Task & { fieldValues?: Record }; let taskList = result.tasks as TaskWithFieldValues[]; if (accessibleIds !== null) { taskList = taskList.filter((t: TaskWithFieldValues) => accessibleIds.has(t.id)); } const [formFields, orgRoles] = await Promise.all([ storage.getFormFields(formId, req.organizationId!), db.select().from(roles).where(eq(roles.organizationId, req.organizationId!)), ]); const roleMap = new Map(orgRoles.map((r) => [r.id, r])); // Build taskTitleMap for task field resolution const taskTitleMap = new Map(); for (const t of taskList) { if (t.id) taskTitleMap.set(t.id, t.title); } const relatedTaskTitles = await buildRelatedTaskTitles(taskList, formFields); for (const [id, title] of Object.entries(relatedTaskTitles)) { const taskId = Number(id); if (!taskTitleMap.has(taskId)) { taskTitleMap.set(taskId, title); } } // Загружаем юрлица для полей типа 'contract-number' const contractNumberFields = formFields.filter((f) => f.type === 'contract-number' && (f as any).options?.contractNumber?.legalEntityFormId); const legalEntityMap = new Map(); for (const f of contractNumberFields) { const leFormId = (f as any).options.contractNumber.legalEntityFormId as number; try { const leTasks = await storage.getTasksWithFieldsByForm(leFormId, req.organizationId!); for (const t of leTasks) { legalEntityMap.set(t.id, t.title); } } catch (err) { console.error('[column-values] error loading legal entities for formId:', leFormId, err); } } // Загружаем справочники для полей типа 'table' чтобы резолвить ID в названия const tableRowMap = new Map>(); const tableFields = formFields.filter((f: any) => { if (f.type !== 'table') return false; const parsed = safeParseOptions(f.options); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) && (parsed as any).tableId; }); const uniqueTableIds = new Set(); for (const f of tableFields) { const parsed = safeParseOptions(f.options); const tableId = (parsed as any).tableId as number; if (tableId) uniqueTableIds.add(tableId); } for (const tableId of uniqueTableIds) { const fullTable = await storage.getDataTableFull(tableId, req.organizationId!); if (!fullTable) continue; const columns = (fullTable.columns as Array<{ name: string }>) || []; const rows = (fullTable.rows as Array<{ id: number; values: string[] }>) || []; const displayColumn = (tableFields.find((f: any) => (f.options as any).tableId === tableId)?.options as any)?.displayColumn || columns[0]?.name; const colIndex = columns.findIndex((c) => c.name === displayColumn); const rowMap = new Map(); for (const row of rows) { const displayValue = colIndex >= 0 ? row.values[colIndex] : row.values[0]; rowMap.set(row.id, displayValue || `#${row.id}`); } tableRowMap.set(tableId, rowMap); } if (contextStr) { try { const context: Record = JSON.parse(contextStr); const statuses = await storage.getFormStatuses(formId, req.organizationId!); const users = await storage.getUsersByOrganization(req.organizationId!); const statusMap = new Map(statuses.map((s: FormStatus) => [s.id, s])); const userMap = new Map(users.map((u: SafeUser) => [u.id, u])); Object.entries(context).forEach(([col, val]) => { if (!val) return; if (col === 'overdue' && val === 'true') { const today = new Date(); today.setHours(0, 0, 0, 0); taskList = taskList.filter((t: Task) => { const isCompleted = t.isCompleted || statusMap.get(t.currentStatusId)?.isFinal; return !isCompleted && t.dueDate && new Date(t.dueDate) < today; }); return; } const valLower = val.toLowerCase(); taskList = taskList.filter((t: Task) => { const tv = getTaskColumnValue(t, col, statusMap, userMap, formFields, roleMap, tableRowMap, legalEntityMap, taskTitleMap); return tv.toLowerCase().includes(valLower); }); }); } catch {} } const statuses = await storage.getFormStatuses(formId, req.organizationId!); const users = await storage.getUsersByOrganization(req.organizationId!); const statusMap = new Map(statuses.map((s: FormStatus) => [s.id, s])); const userMap = new Map(users.map((u: SafeUser) => [u.id, u])); // Кап на количество уникальных значений: параметр limit (дефолт 20, максимум 1000) const limit = clampDirectoryRowsLimit(req.query.limit, COLUMN_VALUES_DEFAULT_LIMIT, COLUMN_VALUES_MAX_LIMIT); const seen = new Set(); const values: string[] = []; for (const t of taskList) { const val = getTaskColumnValue(t, column, statusMap, userMap, formFields, roleMap, tableRowMap, legalEntityMap, taskTitleMap).trim(); if (!val || seen.has(val)) continue; if (search && !val.toLowerCase().includes(search)) continue; seen.add(val); values.push(val); if (values.length >= limit) break; } res.json({ values }); } catch (error) { console.error('Task column values error:', error); res.status(500).json({ error: 'Ошибка получения значений' }); } }); router.post('/api/forms/:id/tasks/aggregate', async (req: AuthenticatedRequest, res) => { try { const formId = parseInt(req.params.id); if (isNaN(formId)) { return res.status(400).json({ success: false, error: 'Неверный ID формы' }); } const existingForm = await storage.getForm(formId, req.organizationId!); if (!existingForm) { return res.status(404).json({ success: false, error: 'Форма не найдена' }); } const { fieldCode, operation, statusId } = req.body; if (!fieldCode || !operation) { return res.status(400).json({ success: false, error: 'Требуется fieldCode и operation' }); } const allowedOperations = ['sum', 'avg', 'count', 'min', 'max']; if (!allowedOperations.includes(operation)) { return res.status(400).json({ success: false, error: `Неверная операция. Допустимые: ${allowedOperations.join(', ')}` }); } const appRole = req.user!.appRole; const [result, accessibleIds] = await Promise.all([ storage.getTasksWithFieldsByFormOptimized(formId, req.organizationId!, {}), storage.getAccessibleTaskIds(req.user!.id, req.organizationId!, appRole), ]); type TaskWithFields = (typeof result.tasks)[0] & { fieldValues?: Record }; let tasks = result.tasks as TaskWithFields[]; if (accessibleIds !== null) { tasks = tasks.filter((t: Task) => accessibleIds.has(t.id)); } let filteredTasks = tasks; if (statusId) { filteredTasks = tasks.filter(t => t.currentStatusId === statusId); } const values: number[] = []; for (const task of filteredTasks) { const fieldValue = task.fieldValues?.[fieldCode]; if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') { const numValue = parseFloat(String(fieldValue)); if (!isNaN(numValue)) { values.push(numValue); } } } let aggregatedValue: number | null = null; switch (operation) { case 'sum': aggregatedValue = values.reduce((a, b) => a + b, 0); break; case 'avg': aggregatedValue = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; break; case 'count': aggregatedValue = values.length; break; case 'min': aggregatedValue = values.length > 0 ? Math.min(...values) : null; break; case 'max': aggregatedValue = values.length > 0 ? Math.max(...values) : null; break; } res.json({ success: true, result: { formId, fieldCode, operation, value: aggregatedValue, taskCount: filteredTasks.length, valuesCount: values.length } }); } catch (error) { console.error('Aggregate tasks error:', error); res.status(500).json({ success: false, error: 'Ошибка при агрегации данных' }); } }); }