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