- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
362 lines
14 KiB
TypeScript
362 lines
14 KiB
TypeScript
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: "Ошибка при обновлении дат Гантта" });
|
||
}
|
||
});
|