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:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user