- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
165 lines
5.4 KiB
TypeScript
165 lines
5.4 KiB
TypeScript
import { Router } from "express";
|
|
import { db } from "../db";
|
|
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
|
import { tenantIsolation } from "../middleware/tenant.middleware";
|
|
import { tasks, forms, formStatuses, taskAssignees } from "@shared/schema";
|
|
import { eq, and, or, gte, lte, sql, inArray, isNotNull, asc, exists } from "drizzle-orm";
|
|
|
|
export const calendarRouter = Router();
|
|
|
|
interface CalendarTask {
|
|
id: number;
|
|
title: string;
|
|
dueDate: string;
|
|
formId: number;
|
|
currentStatusId: number;
|
|
statusColor: string | null;
|
|
statusName: string | null;
|
|
isCompleted: boolean;
|
|
isAllDay: boolean;
|
|
}
|
|
|
|
function parseDateParam(value: unknown): Date | null {
|
|
if (typeof value !== "string" || !value) return null;
|
|
const d = new Date(value);
|
|
if (isNaN(d.getTime())) return null;
|
|
return d;
|
|
}
|
|
|
|
function isMidnight(date: Date): boolean {
|
|
// All-day tasks are stored in the DB with time 00:00:00 (no timezone).
|
|
// Checking UTC midnight makes the detection independent of the client's timezone.
|
|
return date.getUTCHours() === 0 && date.getUTCMinutes() === 0 && date.getUTCSeconds() === 0 && date.getUTCMilliseconds() === 0;
|
|
}
|
|
|
|
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/calendar/tasks?from=ISO&to=ISO&formIds=1,2,3
|
|
// Returns tasks assigned to current user within date range, excluding completed/final tasks.
|
|
calendarRouter.get("/api/calendar/tasks", authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const from = parseDateParam(req.query.from);
|
|
const to = parseDateParam(req.query.to);
|
|
const formIds = parseFormIds(req.query.formIds);
|
|
|
|
if (!from || !to) {
|
|
return res.status(400).json({ success: false, error: "Требуются параметры from и to" });
|
|
}
|
|
|
|
const userId = req.user!.id;
|
|
const organizationId = req.organizationId!;
|
|
|
|
const conditions = [
|
|
eq(forms.organizationId, organizationId),
|
|
eq(tasks.isCompleted, false),
|
|
eq(formStatuses.isFinal, false),
|
|
isNotNull(tasks.dueDate),
|
|
gte(tasks.dueDate, from),
|
|
lte(tasks.dueDate, to),
|
|
or(eq(tasks.assignedTo, userId), exists(
|
|
db
|
|
.select()
|
|
.from(taskAssignees)
|
|
.where(and(
|
|
eq(taskAssignees.taskId, tasks.id),
|
|
eq(taskAssignees.userId, userId)
|
|
))
|
|
))
|
|
];
|
|
|
|
if (formIds) {
|
|
conditions.push(inArray(tasks.formId, formIds));
|
|
}
|
|
|
|
const results = await db
|
|
.select({
|
|
id: tasks.id,
|
|
title: tasks.title,
|
|
dueDate: tasks.dueDate,
|
|
formId: tasks.formId,
|
|
currentStatusId: tasks.currentStatusId,
|
|
statusColor: formStatuses.color,
|
|
statusName: formStatuses.name,
|
|
isCompleted: tasks.isCompleted,
|
|
})
|
|
.from(tasks)
|
|
.innerJoin(forms, eq(tasks.formId, forms.id))
|
|
.innerJoin(formStatuses, eq(tasks.currentStatusId, formStatuses.id))
|
|
.where(and(...conditions))
|
|
.orderBy(asc(tasks.dueDate));
|
|
|
|
const mapped: CalendarTask[] = results.map((row) => {
|
|
const due = row.dueDate ? new Date(row.dueDate) : null;
|
|
return {
|
|
id: row.id,
|
|
title: row.title,
|
|
dueDate: row.dueDate ? row.dueDate.toISOString() : "",
|
|
formId: row.formId,
|
|
currentStatusId: row.currentStatusId,
|
|
statusColor: row.statusColor,
|
|
statusName: row.statusName,
|
|
isCompleted: row.isCompleted ?? false,
|
|
isAllDay: due ? isMidnight(due) : false,
|
|
};
|
|
});
|
|
|
|
res.json({ success: true, tasks: mapped });
|
|
} catch (error) {
|
|
console.error("Calendar tasks error:", error);
|
|
res.status(500).json({ success: false, error: "Ошибка при получении задач календаря" });
|
|
}
|
|
});
|
|
|
|
// PUT /api/calendar/tasks/:id/due-date
|
|
// Updates only dueDate for drag-and-drop rescheduling.
|
|
calendarRouter.put("/api/calendar/tasks/:id/due-date", authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const taskId = parseInt(req.params.id, 10);
|
|
const { dueDate } = req.body;
|
|
|
|
if (isNaN(taskId)) {
|
|
return res.status(400).json({ success: false, error: "Неверный ID задачи" });
|
|
}
|
|
|
|
const newDueDate = dueDate ? new Date(dueDate) : null;
|
|
if (newDueDate && isNaN(newDueDate.getTime())) {
|
|
return res.status(400).json({ success: false, error: "Неверная дата" });
|
|
}
|
|
|
|
const organizationId = req.organizationId!;
|
|
|
|
const [updated] = await db
|
|
.update(tasks)
|
|
.set({
|
|
dueDate: newDueDate,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(
|
|
eq(tasks.id, taskId),
|
|
exists(
|
|
db
|
|
.select()
|
|
.from(forms)
|
|
.where(and(eq(forms.id, tasks.formId), eq(forms.organizationId, organizationId)))
|
|
)
|
|
))
|
|
.returning();
|
|
|
|
if (!updated) {
|
|
return res.status(404).json({ success: false, error: "Задача не найдена" });
|
|
}
|
|
|
|
res.json({ success: true, task: updated });
|
|
} catch (error) {
|
|
console.error("Calendar update due date error:", error);
|
|
res.status(500).json({ success: false, error: "Ошибка при обновлении срока задачи" });
|
|
}
|
|
});
|