fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
361
server/routes/gantt.routes.ts
Normal file
361
server/routes/gantt.routes.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
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: "Ошибка при обновлении дат Гантта" });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user