Files
iistwin/server/routes/gantt.routes.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

362 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Router } from "express";
import { db } from "../db";
import { storage } from "../storage";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { tasks, forms, formStatuses, taskAssignees, taskFieldValues, taskRelations, users } from "@shared/schema";
import { eq, and, or, inArray, sql, isNotNull, asc, exists } from "drizzle-orm";
import {
getGanttFieldsByForm,
getGanttTaskDates,
getRelatedTaskIds,
calculateDateShift,
shiftRelatedTasks,
type GanttFieldInfo,
type GanttTaskDates,
} from "../services/gantt-shift.service";
export const ganttRouter = Router();
interface GanttTask {
id: number;
title: string;
description?: string | null;
formId: number;
currentStatusId: number;
statusName: string | null;
statusColor: string | null;
statusIsFinal: boolean;
assignedTo?: number | null;
assignees?: Array<{ userId: number; user: { firstName: string | null; lastName: string | null; email: string } }>;
createdBy: number;
createdAt: string;
updatedAt: string;
completedAt?: string | null;
dueDate?: string | null;
isCompleted: boolean;
parentTaskId?: number | null;
fieldValues?: Record<number, unknown>;
start: string | null;
end: string | null;
milestones: Array<{ fieldId: number; date: string }>;
}
interface GanttFormMeta {
formId: number;
startFieldId?: number;
endFieldId?: number;
milestoneFieldIds: number[];
}
function parseFormIds(value: unknown): number[] | undefined {
if (typeof value !== "string" || !value) return undefined;
const ids = value
.split(",")
.map((s) => parseInt(s.trim(), 10))
.filter((n) => !isNaN(n));
return ids.length > 0 ? ids : undefined;
}
// GET /api/gantt/tasks?formIds=1,2,3&filters={...}&sortColumn=...&sortDirection=...
ganttRouter.get("/api/gantt/tasks", authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const formIds = parseFormIds(req.query.formIds);
if (!formIds || formIds.length === 0) {
return res.json({ success: true, tasks: [], meta: [], relatedTaskTitles: {} });
}
const organizationId = req.organizationId!;
const userId = req.user!.id;
const appRole = req.user!.appRole;
// Verify forms belong to organization and user can access them
const formRows = await db
.select({ id: forms.id, name: forms.name })
.from(forms)
.where(and(eq(forms.organizationId, organizationId), inArray(forms.id, formIds)));
const validFormIds = formRows.map((f) => f.id);
if (validFormIds.length === 0) {
return res.status(403).json({ success: false, error: "Нет доступа к выбранным формам" });
}
const accessibleIds = await storage.getAccessibleTaskIds(userId, organizationId, appRole);
// Load tasks with fields from selected forms
const taskRows = await db
.select({
id: tasks.id,
title: tasks.title,
description: tasks.description,
formId: tasks.formId,
currentStatusId: tasks.currentStatusId,
assignedTo: tasks.assignedTo,
createdBy: tasks.createdBy,
createdAt: tasks.createdAt,
updatedAt: tasks.updatedAt,
completedAt: tasks.completedAt,
dueDate: tasks.dueDate,
isCompleted: tasks.isCompleted,
parentTaskId: tasks.parentTaskId,
statusName: formStatuses.name,
statusColor: formStatuses.color,
statusIsFinal: formStatuses.isFinal,
})
.from(tasks)
.innerJoin(forms, eq(tasks.formId, forms.id))
.innerJoin(formStatuses, eq(tasks.currentStatusId, formStatuses.id))
.where(and(
eq(forms.organizationId, organizationId),
inArray(tasks.formId, validFormIds)
))
.orderBy(asc(tasks.updatedAt));
let allowedTaskRows = taskRows;
if (accessibleIds !== null) {
allowedTaskRows = taskRows.filter((t) => accessibleIds.has(t.id));
}
const taskIds = allowedTaskRows.map((t) => t.id);
// Load field values
const fieldValuesRows = await db
.select({ taskId: taskFieldValues.taskId, fieldId: taskFieldValues.fieldId, value: taskFieldValues.value })
.from(taskFieldValues)
.where(inArray(taskFieldValues.taskId, taskIds));
const fieldValuesByTask = new Map<number, Record<number, unknown>>();
for (const fv of fieldValuesRows) {
if (!fieldValuesByTask.has(fv.taskId)) fieldValuesByTask.set(fv.taskId, {});
fieldValuesByTask.get(fv.taskId)![fv.fieldId] = fv.value;
}
// Load assignees
const assigneeRows = await db
.select({
taskId: taskAssignees.taskId,
userId: taskAssignees.userId,
firstName: users.firstName,
lastName: users.lastName,
email: users.email,
})
.from(taskAssignees)
.innerJoin(users, eq(taskAssignees.userId, users.id))
.where(inArray(taskAssignees.taskId, taskIds));
const assigneesByTask = new Map<number, Array<{ userId: number; user: { firstName: string | null; lastName: string | null; email: string } }>>();
for (const a of assigneeRows) {
if (!assigneesByTask.has(a.taskId)) assigneesByTask.set(a.taskId, []);
assigneesByTask.get(a.taskId)!.push({
userId: a.userId,
user: { firstName: a.firstName, lastName: a.lastName, email: a.email },
});
}
// Load gantt fields meta
const ganttFields = await getGanttFieldsByForm(validFormIds, organizationId);
const ganttDates = await getGanttTaskDates(taskIds, ganttFields, organizationId);
const datesMap = new Map<number, GanttTaskDates>();
for (const d of ganttDates) datesMap.set(d.taskId, d);
// Build meta per form
const metaByForm = new Map<number, GanttFormMeta>();
for (const formId of validFormIds) {
metaByForm.set(formId, {
formId,
startFieldId: ganttFields.find((f) => f.formId === formId && f.role === "start")?.id,
endFieldId: ganttFields.find((f) => f.formId === formId && f.role === "end")?.id,
milestoneFieldIds: ganttFields.filter((f) => f.formId === formId && f.role === "milestone").map((f) => f.id),
});
}
// Build result tasks
let resultTasks: GanttTask[] = allowedTaskRows.map((t) => {
const dates = datesMap.get(t.id);
const fvs = fieldValuesByTask.get(t.id) || {};
return {
id: t.id,
title: t.title,
description: t.description,
formId: t.formId,
currentStatusId: t.currentStatusId,
statusName: t.statusName,
statusColor: t.statusColor,
statusIsFinal: t.statusIsFinal ?? false,
assignedTo: t.assignedTo,
assignees: assigneesByTask.get(t.id),
createdBy: t.createdBy,
createdAt: t.createdAt ? t.createdAt.toISOString() : "",
updatedAt: t.updatedAt ? t.updatedAt.toISOString() : "",
completedAt: t.completedAt ? t.completedAt.toISOString() : null,
dueDate: t.dueDate ? t.dueDate.toISOString() : null,
isCompleted: t.isCompleted ?? false,
parentTaskId: t.parentTaskId,
fieldValues: fvs,
start: dates?.start ? dates.start.toISOString() : null,
end: dates?.end ? dates.end.toISOString() : null,
milestones: dates?.milestones.map((m) => ({ fieldId: m.fieldId, date: m.date.toISOString() })) || [],
};
});
// Apply filters (server-side subset)
const filtersStr = req.query.filters as string;
if (filtersStr) {
try {
const filters: Record<string, string> = JSON.parse(filtersStr);
// TODO: implement column filtering if needed; for now keep all tasks and let client filter.
} catch { /* ignore */ }
}
// Build related task titles for task-type fields
const relatedTaskTitles: Record<number, string> = {};
const referencedIds = new Set<number>();
for (const t of resultTasks) {
if (!t.fieldValues) continue;
for (const [fieldIdStr, val] of Object.entries(t.fieldValues)) {
const fieldId = parseInt(fieldIdStr);
const field = ganttFields.find((f) => f.id === fieldId);
// We don't have field type here; collect all numeric task refs.
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) {
const relatedRows = await db
.select({ id: tasks.id, title: tasks.title })
.from(tasks)
.innerJoin(forms, eq(tasks.formId, forms.id))
.where(and(inArray(tasks.id, Array.from(referencedIds)), eq(forms.organizationId, organizationId)));
for (const r of relatedRows) {
relatedTaskTitles[r.id] = r.title || `#${r.id}`;
}
}
res.json({
success: true,
tasks: resultTasks,
meta: Array.from(metaByForm.values()),
relatedTaskTitles,
});
} catch (error) {
console.error("Gantt tasks error:", error);
res.status(500).json({ success: false, error: "Ошибка при получении задач Гантта" });
}
});
// GET /api/gantt/relations?taskIds=1,2,3
ganttRouter.get("/api/gantt/relations", authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const taskIds = parseFormIds(req.query.taskIds);
if (!taskIds || taskIds.length === 0) {
return res.json({ success: true, relations: [] });
}
const organizationId = req.organizationId!;
const relations = await db
.select({ parentTaskId: taskRelations.parentTaskId, childTaskId: taskRelations.childTaskId })
.from(taskRelations)
.where(and(
eq(taskRelations.organizationId, organizationId),
or(
inArray(taskRelations.parentTaskId, taskIds),
inArray(taskRelations.childTaskId, taskIds)
)
));
res.json({ success: true, relations });
} catch (error) {
console.error("Gantt relations error:", error);
res.status(500).json({ success: false, error: "Ошибка при получении связей Гантта" });
}
});
// PUT /api/gantt/tasks/:id/dates — update gantt dates with cascade shift
ganttRouter.put("/api/gantt/tasks/:id/dates", authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const taskId = parseInt(req.params.id, 10);
if (isNaN(taskId)) {
return res.status(400).json({ success: false, error: "Неверный ID задачи" });
}
const { start, end, startFieldId, endFieldId } = req.body;
const organizationId = req.organizationId!;
const existingTask = await storage.getTask(taskId, organizationId);
if (!existingTask) {
return res.status(404).json({ success: false, error: "Задача не найдена" });
}
const hasAccess = await storage.canUserAccessTask(taskId, req.user!.id, organizationId, req.user!.appRole);
if (!hasAccess) {
return res.status(403).json({ success: false, error: "Нет доступа к этой задаче" });
}
const ganttFields = await getGanttFieldsByForm([existingTask.formId], organizationId);
const dates = await getGanttTaskDates([taskId], ganttFields, organizationId);
const currentDates = dates[0];
let deltaMs = 0;
const updatedFields: Array<{ fieldId: number; value: string }> = [];
if (startFieldId && start !== undefined) {
const newStart = start ? new Date(start) : null;
const oldStart = currentDates?.start || null;
if (newStart && !isNaN(newStart.getTime())) {
updatedFields.push({ fieldId: startFieldId, value: newStart.toISOString() });
if (deltaMs === 0 && oldStart) {
deltaMs = calculateDateShift(oldStart, newStart);
}
}
}
if (endFieldId && end !== undefined) {
const newEnd = end ? new Date(end) : null;
const oldEnd = currentDates?.end || null;
if (newEnd && !isNaN(newEnd.getTime())) {
updatedFields.push({ fieldId: endFieldId, value: newEnd.toISOString() });
if (deltaMs === 0 && oldEnd) {
deltaMs = calculateDateShift(oldEnd, newEnd);
}
}
}
if (updatedFields.length === 0) {
return res.status(400).json({ success: false, error: "Не переданы даты для обновления" });
}
// Apply updates to the source task
for (const { fieldId, value } of updatedFields) {
await storage.updateTaskFieldValue(taskId, fieldId, organizationId, { value });
}
// Cascade shift related tasks
const shiftResult = await shiftRelatedTasks(taskId, deltaMs, organizationId, { triggeredByUserId: req.user?.id });
// Invalidate caches
const { tasksMinimalCache } = await import("../utils/cache");
tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`);
res.json({ success: true, taskId, deltaMs, shifted: shiftResult.shifted, errors: shiftResult.errors });
} catch (error) {
console.error("Gantt update dates error:", error);
res.status(500).json({ success: false, error: "Ошибка при обновлении дат Гантта" });
}
});