Files
iistwin/server/routes/dashboard.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

169 lines
5.8 KiB
TypeScript

import { Router } from "express";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { storage } from "../storage";
import { pool } from "../db";
const dashboardRouter = Router();
/**
* GET /api/dashboard
*
* Query params:
* formIds — comma-separated list of form IDs (default: "11")
*
* Returns aggregated analytics for the selected forms:
* - forms: available forms for the user
* - statusCounts: Record<formId, Array<{ statusId, name, color, count }>>
* - overdueTasks: top 10 overdue tasks across selected forms
* - myTasks: top 10 open tasks assigned to current user across selected forms
* - assigneeDistribution: Record<formId, Array<{ userId, userName, count }>>
*/
dashboardRouter.get('/api/dashboard',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const orgId = req.user!.organizationId;
// Parse formIds parameter
const formIdsParam = (req.query.formIds as string) || '11';
const formIds = formIdsParam
.split(',')
.map((s) => parseInt(s.trim(), 10))
.filter((n) => !isNaN(n));
if (formIds.length === 0) {
return res.status(400).json({ success: false, error: 'Необходимо указать хотя бы одну форму' });
}
// Verify access to each requested form
const accessibleFormIds: number[] = [];
for (const fid of formIds) {
const canAccess = await storage.canUserAccessForm(userId, fid, orgId, 'participate');
if (canAccess) accessibleFormIds.push(fid);
}
if (accessibleFormIds.length === 0) {
return res.status(403).json({ success: false, error: 'Нет доступа к выбранным формам' });
}
// Get form details for accessible forms
const formsResult = await pool.query(
`SELECT id, name FROM forms WHERE id = ANY($1) AND organization_id = $2`,
[accessibleFormIds, orgId]
);
const forms = formsResult.rows;
// Status counts per form
const statusCounts: Record<number, Array<{ statusId: number; name: string; color: string; count: number }>> = {};
for (const fid of accessibleFormIds) {
const counts = await storage.getTaskCountsByStatus(fid, orgId);
statusCounts[fid] = counts;
}
// Overdue tasks (due_date < NOW(), not completed) across selected forms
const overdueResult = await pool.query(
`
SELECT
t.id,
t.title,
t.form_id AS "formId",
f.name AS "formName",
fs.name AS "statusName",
fs.color AS "statusColor",
t.due_date AS "dueDate",
COALESCE(
CONCAT(u.first_name, ' ', u.middle_name, ' ', u.last_name),
CONCAT(u.first_name, ' ', u.last_name)
) AS "assigneeName"
FROM tasks t
JOIN forms f ON f.id = t.form_id AND f.organization_id = $1
JOIN form_statuses fs ON fs.id = t.current_status_id
LEFT JOIN users u ON u.id = t.assigned_to
WHERE t.form_id = ANY($2)
AND t.is_completed = false
AND t.due_date IS NOT NULL
AND t.due_date < NOW()
ORDER BY t.due_date ASC
LIMIT 10
`,
[orgId, accessibleFormIds]
);
// My tasks (assigned to current user, not completed) across selected forms
const myTasksResult = await pool.query(
`
SELECT
t.id,
t.title,
t.form_id AS "formId",
f.name AS "formName",
fs.name AS "statusName",
fs.color AS "statusColor",
t.due_date AS "dueDate",
COALESCE(
CONCAT(u.first_name, ' ', u.middle_name, ' ', u.last_name),
CONCAT(u.first_name, ' ', u.last_name)
) AS "assigneeName"
FROM tasks t
JOIN forms f ON f.id = t.form_id AND f.organization_id = $1
JOIN form_statuses fs ON fs.id = t.current_status_id
LEFT JOIN users u ON u.id = t.assigned_to
WHERE t.form_id = ANY($2)
AND t.is_completed = false
AND t.assigned_to = $3
ORDER BY t.due_date ASC NULLS LAST, t.updated_at DESC
LIMIT 10
`,
[orgId, accessibleFormIds, userId]
);
// Assignee distribution per form
const assigneeDistribution: Record<number, Array<{ userId: number | null; userName: string; count: number }>> = {};
for (const fid of accessibleFormIds) {
const distResult = await pool.query(
`
SELECT
t.assigned_to AS "userId",
COALESCE(
CONCAT(u.first_name, ' ', u.middle_name, ' ', u.last_name),
CONCAT(u.first_name, ' ', u.last_name),
'Без назначения'
) AS "userName",
COUNT(*)::int AS "count"
FROM tasks t
LEFT JOIN users u ON u.id = t.assigned_to
WHERE t.form_id = $1
AND t.is_completed = false
AND t.organization_id = $2
GROUP BY t.assigned_to, u.first_name, u.middle_name, u.last_name
ORDER BY "count" DESC
`,
[fid, orgId]
);
assigneeDistribution[fid] = distResult.rows;
}
res.json({
success: true,
data: {
forms,
statusCounts,
overdueTasks: overdueResult.rows,
myTasks: myTasksResult.rows,
assigneeDistribution,
},
});
} catch (error) {
console.error('[dashboard]', error);
res.status(500).json({ success: false, error: 'Ошибка загрузки дашборда' });
}
}
);
export function registerDashboardRoutes(router: Router): void {
router.use(dashboardRouter);
}