feat(users): выбор колонок в реестре пользователей + кликабельная Оценка и доработанный график истории

Реестр /users:
- кнопка «Колонки» — чекбоксы базовых колонок и кастомных полей профиля
  (Оценка, Средняя оценка и др.), скрытые ключи в localStorage;
- /api/users/list?includeFieldValues=1 — значения полей текущей страницы;
- поля типа history-number (Оценка) кликабельны — открывают график истории.

График истории (FieldHistoryDialog):
- точки красные и крупные (на тёмном фоне дефолтные не видны), линия primary;
- клик по точке — под графиком список оценок за бакет со ссылками на задачи
  (название задачи → /forms/<formId>/tasks/<id>);
- API field-history возвращает entries (value, taskId, taskTitle, formId, bucket).
This commit is contained in:
2026-09-29 15:09:22 +03:00
parent c56ec26b49
commit c0011b49a3
5 changed files with 330 additions and 44 deletions

View File

@@ -224,11 +224,20 @@ export function registerUserProfileRoutes(router: Router): void {
period.days,
period.bucket
);
// Сырые записи с привязкой к задачам — для клика по точке графика
const entries = await storage.getUserFieldHistoryEntries(
userId,
fieldId,
req.organizationId!,
period.days,
period.bucket
);
res.json({
success: true,
field: { id: field.id, name: field.name },
period: periodParam,
points,
entries,
overallAvg,
totalCount,
});

View File

@@ -17,7 +17,7 @@ import {
bulkAssignDelegate,
} from "../services/delegation.service";
import { db } from "../db";
import { roles, roleMembers, userStatuses, insertUserStatusSchema, updateUserStatusSchema } from "@shared/schema";
import { roles, roleMembers, userStatuses, userProfileFieldValues, insertUserStatusSchema, updateUserStatusSchema } from "@shared/schema";
import { eq, and, inArray } from "drizzle-orm";
export function registerUserRoutes(router: ReturnType<typeof import("express").Router>): void {
@@ -135,6 +135,21 @@ export function registerUserRoutes(router: ReturnType<typeof import("express").R
rolesByUser.get(ur.userId)!.push({ id: ur.roleId, name: ur.roleName });
}
// Значения кастомных полей профиля — для реестра пользователей (выбор колонок).
// Только для текущей страницы (userIds), опционально по includeFieldValues=1.
const includeFieldValues = req.query.includeFieldValues === '1';
const fieldValuesByUser = new Map<number, Record<number, unknown>>();
if (includeFieldValues && userIds.length > 0) {
const fvRows = await db
.select({ userId: userProfileFieldValues.userId, fieldId: userProfileFieldValues.fieldId, value: userProfileFieldValues.value })
.from(userProfileFieldValues)
.where(inArray(userProfileFieldValues.userId, userIds));
for (const row of fvRows) {
if (!fieldValuesByUser.has(row.userId)) fieldValuesByUser.set(row.userId, {});
fieldValuesByUser.get(row.userId)![row.fieldId] = row.value;
}
}
const allStatuses = await userStatusesStorage.listByOrganization(req.organizationId!);
const statusById = new Map(allStatuses.map(s => [s.id, s]));
@@ -158,6 +173,7 @@ export function registerUserRoutes(router: ReturnType<typeof import("express").R
lastLogin: u.lastLogin,
createdAt: u.createdAt,
organizationalRoles: rolesByUser.get(u.id) || [],
...(includeFieldValues ? { fieldValues: fieldValuesByUser.get(u.id) || {} } : {}),
};
}),
total: managedIds === null ? result.total : filteredUsers.length,

View File

@@ -565,6 +565,49 @@ export class UserProfileStorage extends UsersStorage {
};
}
// Сырые записи истории с привязкой к задачам — для клика по точке графика
// (список задач за бакет: название + ссылка). bucket считается тем же
// date_trunc, что и у точек графика, чтобы клиент мог их сопоставить.
async getUserFieldHistoryEntries(
userId: number,
fieldId: number,
organizationId: number,
days: number,
bucket: 'day' | 'week' | 'month'
): Promise<Array<{
value: number;
taskId: number | null;
taskTitle: string | null;
formId: number | null;
bucket: string;
createdAt: string;
}>> {
const result = await db.execute(sql`
SELECT ufh.value::float8 AS value,
ufh.task_id AS "taskId",
t.title AS "taskTitle",
t.form_id AS "formId",
to_char(date_trunc(${bucket}, ufh.created_at), 'YYYY-MM-DD') AS bucket,
ufh.created_at AS "createdAt"
FROM user_field_history ufh
LEFT JOIN tasks t ON ufh.task_id = t.id
WHERE ufh.user_id = ${userId}
AND ufh.field_id = ${fieldId}
AND ufh.organization_id = ${organizationId}
AND ufh.created_at >= now() - make_interval(days => ${days})
ORDER BY ufh.created_at DESC
LIMIT 500
`);
return result.rows as Array<{
value: number;
taskId: number | null;
taskTitle: string | null;
formId: number | null;
bucket: string;
createdAt: string;
}>;
}
// ========== DETAIL ==========
async getUserProfileDetail(
userId: number,