diff --git a/client/src/components/CreateTaskFormContent.tsx b/client/src/components/CreateTaskFormContent.tsx index 13ae64c..63d2248 100644 --- a/client/src/components/CreateTaskFormContent.tsx +++ b/client/src/components/CreateTaskFormContent.tsx @@ -727,6 +727,7 @@ export function CreateTaskFormContent({ diff --git a/client/src/components/RegistryCellEditor.tsx b/client/src/components/RegistryCellEditor.tsx index 0c48ee7..15aed16 100644 --- a/client/src/components/RegistryCellEditor.tsx +++ b/client/src/components/RegistryCellEditor.tsx @@ -10,6 +10,7 @@ import ContactField from '@/components/ContactField'; import { normalizeSelectOptions } from '@/lib/fieldOptions'; import { fieldControlClass } from '@/components/fields/field-styles'; import { SearchableMultiSelect } from '@/components/fields/SearchableMultiSelect'; +import { UserRoleMultiSelect } from '@/components/fields/editors/UserRoleMultiSelect'; import { AutoResizeTextarea } from '@/components/ui/auto-resize-textarea'; interface FormFieldData { @@ -284,6 +285,22 @@ export default function RegistryCellEditor({ ? roles.filter((r) => filter.roleIds!.includes(r.id)) : (roles || []); + // Множественный выбор (allowMultiple): значение — массив ["user:5", "role:2"], + // сохранение — по клику вне ячейки (общий механизм редактора) + if (field.allowMultiple) { + return ( +
e.stopPropagation()}> + setEditValue(val)} + /> +
+ ); + } + const stringValue = (() => { if (editValue === null || editValue === undefined || editValue === '') return '__none__'; if (typeof editValue === 'number') return `user:${editValue}`; diff --git a/client/src/components/TaskRegistry.tsx b/client/src/components/TaskRegistry.tsx index 009a54e..ecf74a9 100644 --- a/client/src/components/TaskRegistry.tsx +++ b/client/src/components/TaskRegistry.tsx @@ -513,6 +513,10 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = } else if (field?.type === 'task') { const display = displayFieldValue(field, val, displayContext); if (display.toLowerCase().includes(qLower)) return true; + } else if (field?.type === 'user' || field?.type === 'role') { + // В т.ч. мультивыбор: массив ["user:5", "role:2"] — ищем по отображаемым именам + const display = displayFieldValue(field, val, displayContext); + if (display.toLowerCase().includes(qLower)) return true; } else { const str = Array.isArray(val) ? (val as Record[]).map(f => f?.name ?? '').join(' ') diff --git a/client/src/components/fields/editors/UserRoleEditor.tsx b/client/src/components/fields/editors/UserRoleEditor.tsx index 6e24118..a6f4ea7 100644 --- a/client/src/components/fields/editors/UserRoleEditor.tsx +++ b/client/src/components/fields/editors/UserRoleEditor.tsx @@ -9,6 +9,8 @@ import { SelectItem, } from '@/components/ui/select'; import type { FieldEditorProps } from '../FieldRegistry'; +import { UserRoleMultiSelect } from './UserRoleMultiSelect'; +import { parseUserFieldValue, normalizeUserFieldEntry } from '@/lib/userFieldValue'; export const UserRoleEditor: React.FC = ({ field, value, onChange, users, roles, autoOpen = false }) => { const filter = (field.options as any)?.userFilter as { userIds?: number[]; roleIds?: number[] } | undefined; @@ -73,6 +75,43 @@ export const UserRoleEditor: React.FC const hasUsers = filteredUsers.length > 0; const hasRoles = filteredRoles.length > 0; + // Множественный выбор (allowMultiple): чекбокс-список пользователей и ролей, + // значение — массив строк ["user:5", "role:2"]. + // Текущие выбранные значения добавляем в опции с настоящими именами, + // даже если они не проходят userFilter (как в одиночном режиме выше). + if (field.allowMultiple) { + const entries = parseUserFieldValue(value) + .map(normalizeUserFieldEntry) + .filter((e): e is string => !!e); + let multiUsers = filteredUsers; + let multiRoles = filteredRoles; + for (const entry of entries) { + if (entry.startsWith('user:')) { + const id = Number(entry.slice(5)); + if (!multiUsers.some((u) => u.id === id)) { + const u = users?.find((x) => x.id === id); + if (u) multiUsers = [u, ...multiUsers]; + } + } else if (entry.startsWith('role:')) { + const id = Number(entry.slice(5)); + if (!multiRoles.some((r) => r.id === id)) { + const r = roles?.find((x) => x.id === id); + if (r) multiRoles = [r, ...multiRoles]; + } + } + } + return ( + + ); + } + return ( onChange(e.target.value)} + placeholder={field.placeholder} + required={field.isRequired} + /> + + + + ); +} + function UserProfileFieldInput({ field, value, onChange, users, + userId, }: { field: UserProfileField; value: any; onChange: (val: any) => void; users: Array<{ id: number; firstName?: string; lastName?: string; middleName?: string; email: string }>; + userId: number; }) { switch (field.type) { case 'text': case 'contract-number': - case 'history-number': return ( ); + case 'history-number': + return ( + + ); case 'number': return ( setCustomValues((prev) => ({ ...prev, [field.id]: val }))} users={allUsers} + userId={userId} /> ))} diff --git a/migrations/0078_user_field_history.sql b/migrations/0078_user_field_history.sql new file mode 100644 index 0000000..8a310da --- /dev/null +++ b/migrations/0078_user_field_history.sql @@ -0,0 +1,17 @@ +-- История числовых значений пользовательских полей типа history-number. +-- Заполняется автоматизациями через ctx.users.setFieldValue (рейтинги, оценки и т.п.). +-- task_id — задача, из которой выставлена оценка (может быть NULL). +CREATE TABLE IF NOT EXISTS user_field_history ( + id serial PRIMARY KEY, + organization_id integer NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, + field_id integer NOT NULL REFERENCES user_profile_fields(id) ON DELETE CASCADE, + value numeric NOT NULL, + task_id integer REFERENCES tasks(id) ON DELETE SET NULL, + created_at timestamp DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS user_field_history_user_field_created_idx + ON user_field_history (user_id, field_id, created_at); +CREATE INDEX IF NOT EXISTS user_field_history_org_idx + ON user_field_history (organization_id); diff --git a/server/mcp.ts b/server/mcp.ts index 732319f..b5ca252 100644 --- a/server/mcp.ts +++ b/server/mcp.ts @@ -9,6 +9,7 @@ import { authService } from "./services/auth.service"; import { FORM_FIELD_TYPES, GLOBAL_FIELD_TYPES, TABLE_FIELD_TYPES } from "../shared/field-types"; import { normalizeFieldValueForStorage } from "./utils/normalize-field-value"; import { normalizeFileUrl, normalizeFileFieldUrls } from "./utils/upload"; +import { parseUserFieldEntries } from "./utils/user-field-value"; // In-process dedup for legacy-key admin notifications (reset on process restart) @@ -948,14 +949,27 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes, apiKeyReco } } - // Sync task_assignees из user-полей, чтобы задача попадала в inbox (как REST POST — только добавление) + // Sync task_assignees из user-полей, чтобы задача попадала в inbox (как REST POST — только добавление). + // Мультивыбор: массив ["user:5", "role:2"]; role: → все члены роли. for (const fv of fieldValues) { const field = fieldMap.get(fv.fieldId)!; if (field.type !== 'user') continue; - const raw = fv.value === null || fv.value === undefined || fv.value === '' ? '' : String(fv.value); - const userId = raw ? Number(raw.replace(/^user:/, '')) : NaN; - if (!isNaN(userId) && userId > 0) { - await storage.addTaskAssignee(taskId, userId, organizationId).catch(() => {}); + for (const entry of parseUserFieldEntries(fv.value)) { + if (entry.startsWith('role:')) { + const roleId = Number(entry.replace('role:', '')); + if (isNaN(roleId)) continue; + const members = await storage.getRoleMembersByRole(roleId).catch(() => []); + for (const m of members) { + if (m.userId) { + await storage.addTaskAssignee(taskId, m.userId, organizationId).catch(() => {}); + } + } + continue; + } + const userId = Number(entry.replace(/^user:/, '')); + if (!isNaN(userId) && userId > 0) { + await storage.addTaskAssignee(taskId, userId, organizationId).catch(() => {}); + } } } @@ -2409,9 +2423,10 @@ Users: - ctx.users.get(userId) — get one user - ctx.users.update(userId, updates) — update user fields (firstName, lastName, position, statusId, etc.) - ctx.users.updateStatus(userId, statusId) — change user status +- ctx.users.setFieldValue(userId, fieldCodeOrId, value) — set a user profile custom field value by field id or code (with audit as 'Автоматизация'). For fields of type 'history-number' the numeric value is also appended to user_field_history (linked to the trigger task); a non-numeric value throws an error. Trigger types: 'manual', 'task.before_create', 'task.created', 'task.status_changed', 'qr.scan', 'schedule' -TriggerConfig for task.before_create / task.created / task.status_changed: { formId: number, statusId?: number } +TriggerConfig for task.before_create / task.created / task.status_changed: { formId: number, statusId?: number } — statusId applies only to task.status_changed and limits firing to transitions INTO that status TriggerConfig for qr.scan: { templateKey: string } TriggerConfig for schedule: { time: "HH:MM" } — daily run at this Moscow time (MSK, UTC+3); reruns within the same day are prevented via last_scheduled_run_at diff --git a/server/routes/automation.routes.ts b/server/routes/automation.routes.ts index 330bd56..1029c57 100644 --- a/server/routes/automation.routes.ts +++ b/server/routes/automation.routes.ts @@ -29,6 +29,7 @@ const ALLOWED_STORAGE_METHODS = new Set([ 'getUser', 'getUsersByOrganization', 'updateUser', + 'setUserProfileFieldValueFromAutomation', ]); interface WorkerStorageCall { @@ -158,6 +159,13 @@ export async function runAutomationsByTrigger( if (Number(configFormId) !== Number(dataFormId)) continue; } + // Для task.status_changed: triggerConfig.statusId ограничивает срабатывание + // переходом ТОЛЬКО в указанный статус (без statusId — на любую смену статуса) + if (trigger === 'task.status_changed' && config.statusId !== undefined && triggerData && typeof triggerData === 'object') { + const data = triggerData as Record; + if (Number(config.statusId) !== Number(data.newStatusId)) continue; + } + const { logs, error, result } = await runAutomationInWorker( automation.code, organizationId, diff --git a/server/routes/shared.ts b/server/routes/shared.ts index 7f96c18..8b2a4d5 100644 --- a/server/routes/shared.ts +++ b/server/routes/shared.ts @@ -391,6 +391,17 @@ export function formatFieldValueForTitle(val: any, fieldType?: string, ctx?: Fie switch (fieldType) { case 'user': { + // Мультивыбор (allow_multiple): массив ["user:5", "role:2"] — имена через запятую + let parsedUserVal: any = val; + if (typeof val === 'string' && val.startsWith('[')) { + try { parsedUserVal = JSON.parse(val); } catch { parsedUserVal = val; } + } + if (Array.isArray(parsedUserVal)) { + return parsedUserVal + .map((entry: unknown) => formatFieldValueForTitle(String(entry), 'user', ctx)) + .filter(Boolean) + .join(', '); + } if (rawStr.startsWith('user:')) { const uid = Number(rawStr.replace('user:', '')); const user = ctx?.usersMap?.get(uid) ?? (ctx?.usersMap as any)?.get(String(uid)); diff --git a/server/routes/task-crud-list.routes.ts b/server/routes/task-crud-list.routes.ts index 5416ead..5cc1840 100644 --- a/server/routes/task-crud-list.routes.ts +++ b/server/routes/task-crud-list.routes.ts @@ -345,6 +345,30 @@ export function registerTaskCrudListRoutes(router: ReturnType` / `role:` / legacy-число) + function resolveUserColumnEntry( + entry: string, + userMap: Map, + roleMap?: Map + ): string { + if (entry.startsWith('user:')) { + const uid = Number(entry.replace('user:', '')); + const u = userMap.get(uid); + return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : entry; + } + if (entry.startsWith('role:')) { + const rid = Number(entry.replace('role:', '')); + const r = roleMap?.get(rid); + return r?.name ? `Роль: ${r.name}` : entry; + } + const uid = Number(entry); + if (!isNaN(uid)) { + const u = userMap.get(uid); + return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : entry; + } + return entry; + } + function getTaskColumnValue( task: Task & { fieldValues?: Record; assignees?: Array<{ userId: number; user?: { firstName: string | null; middleName: string | null; lastName: string | null } }> }, column: string, @@ -406,17 +430,18 @@ export function registerTaskCrudListRoutes(router: ReturnType resolveUserColumnEntry(String(entry), userMap, roleMap)) + .filter(Boolean) + .join(', '); } - return str; + return resolveUserColumnEntry(str, userMap, roleMap); } case 'role': { if (str.startsWith('role:')) { diff --git a/server/routes/task-fields.routes.ts b/server/routes/task-fields.routes.ts index 72ddca8..4532dc4 100644 --- a/server/routes/task-fields.routes.ts +++ b/server/routes/task-fields.routes.ts @@ -13,6 +13,7 @@ import { validateImmutableFields } from "../utils/validate-immutable-fields"; import { shiftRelatedTasks, calculateDateShift } from "../services/gantt-shift.service"; import { normalizeFieldValueForStorage } from "../utils/normalize-field-value"; import { normalizeFileFieldUrls } from "../utils/upload"; +import { parseUserFieldEntries } from "../utils/user-field-value"; function extractUserId(value: unknown): number | null { if (value === null || value === undefined || value === '') return null; @@ -25,6 +26,31 @@ function extractUserId(value: unknown): number | null { return isNaN(uid) ? null : uid; } +/** + * Резолвит значение user-поля (одиночное или массив ["user:5", "role:2"]) + * в список userId для синка в task_assignees: + * - user: / legacy-число → сам пользователь; + * - role: → все члены роли (как в автопереходах — storage.getRoleMembersByRole). + */ +async function resolveAssigneeUserIds(value: unknown, organizationId: number): Promise { + const entries = parseUserFieldEntries(value); + const ids = new Set(); + for (const entry of entries) { + if (entry.startsWith('role:')) { + const roleId = Number(entry.replace('role:', '')); + if (isNaN(roleId)) continue; + const members = await storage.getRoleMembersByRole(roleId).catch(() => []); + for (const m of members) { + if (m.userId) ids.add(m.userId); + } + continue; + } + const uid = extractUserId(entry); + if (uid) ids.add(uid); + } + return Array.from(ids); +} + export function registerTaskFieldRoutes(router: ReturnType): void { router.get('/api/tasks/:id/field-values', authenticateToken, @@ -275,11 +301,12 @@ export function registerTaskFieldRoutes(router: ReturnType → пользователь, role: → члены роли) for (const createdValue of createdValues) { const fld = fieldMap.get(createdValue.fieldId); if (fld?.type === 'user') { - const userId = extractUserId(createdValue.value); - if (userId) { + const userIds = await resolveAssigneeUserIds(createdValue.value, req.organizationId!); + for (const userId of userIds) { await storage.addTaskAssignee(taskId, userId, req.organizationId!).catch(() => {}); } } @@ -532,15 +559,21 @@ export function registerTaskFieldRoutes(router: ReturnType → члены роли. if (field.type === 'user') { - const newUserId = extractUserId(savedValue.value); - const oldUserId = extractUserId(oldVal); - if (newUserId && newUserId !== oldUserId) { - await storage.addTaskAssignee(taskId, newUserId, req.organizationId!).catch(() => {}); + const newUserIds = await resolveAssigneeUserIds(savedValue.value, req.organizationId!); + const oldUserIds = await resolveAssigneeUserIds(oldVal, req.organizationId!); + for (const userId of newUserIds) { + if (!oldUserIds.includes(userId)) { + await storage.addTaskAssignee(taskId, userId, req.organizationId!).catch(() => {}); + } } - if (oldUserId && oldUserId !== newUserId) { - await storage.removeTaskAssignee(taskId, oldUserId, req.organizationId!).catch(() => {}); + for (const userId of oldUserIds) { + if (!newUserIds.includes(userId)) { + await storage.removeTaskAssignee(taskId, userId, req.organizationId!).catch(() => {}); + } } } @@ -731,11 +764,12 @@ export function registerTaskFieldRoutes(router: ReturnType → пользователь, role: → члены роли) for (const sv of savedValues) { const fld = fieldMap.get(sv.fieldId); if (fld?.type === 'user') { - const userId = extractUserId(sv.value); - if (userId) { + const userIds = await resolveAssigneeUserIds(sv.value, req.organizationId!); + for (const userId of userIds) { await storage.addTaskAssignee(taskId, userId, req.organizationId!).catch(() => {}); } } diff --git a/server/routes/user-profile.routes.ts b/server/routes/user-profile.routes.ts index 18c0f52..f76e56e 100644 --- a/server/routes/user-profile.routes.ts +++ b/server/routes/user-profile.routes.ts @@ -175,6 +175,60 @@ export function registerUserProfileRoutes(router: Router): void { } ); + // === USER FIELD HISTORY (history-number) === + const FIELD_HISTORY_PERIODS: Record = { + week: { days: 7, bucket: 'day' }, + month: { days: 30, bucket: 'day' }, + quarter: { days: 90, bucket: 'week' }, + year: { days: 365, bucket: 'month' }, + }; + + router.get('/api/users/:id/field-history/:fieldId', + authenticateToken, + tenantIsolation, + async (req: AuthenticatedRequest, res) => { + try { + const userId = parseInt(req.params.id); + const fieldId = parseInt(req.params.fieldId); + if (isNaN(userId) || isNaN(fieldId)) { + return res.status(400).json({ success: false, error: 'Неверный ID' }); + } + // Права — как у просмотра профиля: любой сотрудник той же организации + const targetUser = await storage.getUser(userId); + if (!targetUser || targetUser.organizationId !== req.organizationId) { + return res.status(404).json({ success: false, error: 'Пользователь не найден' }); + } + const field = await storage.getUserProfileFieldById(fieldId, req.organizationId!); + if (!field || field.type !== 'history-number') { + return res.status(404).json({ success: false, error: 'Поле не найдено' }); + } + const periodParam = String(req.query.period || 'month'); + const period = FIELD_HISTORY_PERIODS[periodParam]; + if (!period) { + return res.status(400).json({ success: false, error: 'Неверный период. Допустимо: week, month, quarter, year' }); + } + const { points, overallAvg, totalCount } = await storage.getUserFieldHistoryPoints( + userId, + fieldId, + req.organizationId!, + period.days, + period.bucket + ); + res.json({ + success: true, + field: { id: field.id, name: field.name }, + period: periodParam, + points, + overallAvg, + totalCount, + }); + } catch (error) { + console.error('Get user field history error:', error); + res.status(500).json({ success: false, error: 'Ошибка при получении истории поля' }); + } + } + ); + // === USER PROFILE FIELD VALUES === router.get('/api/users/:id/field-values', authenticateToken, diff --git a/server/services/task-transition.service.ts b/server/services/task-transition.service.ts index dcc654f..9b1352f 100644 --- a/server/services/task-transition.service.ts +++ b/server/services/task-transition.service.ts @@ -7,6 +7,7 @@ import { indexTaskAsync } from "../routes/task-helpers"; import { fieldConditionsMatch, resolveFieldDisplayText, isFieldValueEmpty, SYSTEM_TASK_FIELDS, buildSystemFieldValuesForConditions } from "../utils/field-conditions"; import { validateRequiredFieldsDetailed } from "../utils/validate-required-fields"; import { ensureReviewAssignees, cleanupStaleDelegates } from "./delegation.service"; +import { runAutomationsByTrigger } from "../routes/automation.routes"; export interface ExecuteTaskTransitionOptions { taskId: number; @@ -528,6 +529,18 @@ export async function executeTaskTransition(options: ExecuteTaskTransitionOption indexTaskAsync(taskId, organizationId).catch(() => {}); + // Автоматизации по триггеру task.status_changed — fire-and-forget, + // чтобы ошибки/таймауты скриптов не ломали сам переход + if (targetStatusId !== transition.fromStatusId) { + runAutomationsByTrigger(organizationId, 'task.status_changed', { + formId: existingTask.formId, + taskId, + oldStatusId: transition.fromStatusId, + newStatusId: targetStatusId, + task: updatedTask, + }).catch(err => console.error('[automation] task.status_changed error:', err)); + } + return { success: true, message: 'Переход выполнен успешно', diff --git a/server/storage/user-profile.storage.ts b/server/storage/user-profile.storage.ts index 27af9e5..a0a55ea 100644 --- a/server/storage/user-profile.storage.ts +++ b/server/storage/user-profile.storage.ts @@ -3,6 +3,7 @@ import { userProfileFields, userProfileFieldValues, userProfileAuditLog, + userFieldHistory, roleMembers, roles, userStatuses, @@ -16,7 +17,7 @@ import { type InsertUserProfileAuditLog, } from "@shared/schema"; import { db } from "../db"; -import { eq, and, desc, inArray } from "drizzle-orm"; +import { eq, and, desc, inArray, sql } from "drizzle-orm"; import { UsersStorage } from "./users.storage"; export class UserProfileStorage extends UsersStorage { @@ -121,6 +122,71 @@ export class UserProfileStorage extends UsersStorage { return field || undefined; } + async getUserProfileFieldByCode(code: string, organizationId: number): Promise { + const [field] = await db + .select() + .from(userProfileFields) + .where(and(eq(userProfileFields.code, code), eq(userProfileFields.organizationId, organizationId))); + return field || undefined; + } + + // Запись значения поля профиля из автоматизации (ctx.users.setFieldValue): + // upsert значения, аудит с актором «Автоматизация», для полей history-number — запись в user_field_history. + async setUserProfileFieldValueFromAutomation( + userId: number, + fieldCodeOrId: string | number, + value: unknown, + organizationId: number, + taskId?: number | null + ): Promise { + const user = await this.getUser(userId); + if (!user || user.organizationId !== organizationId) { + throw new Error(`Пользователь ${userId} не найден в организации`); + } + + const field = typeof fieldCodeOrId === 'number' + ? await this.getUserProfileFieldById(fieldCodeOrId, organizationId) + : await this.getUserProfileFieldByCode(String(fieldCodeOrId), organizationId); + if (!field) { + throw new Error(`Поле профиля "${fieldCodeOrId}" не найдено`); + } + + const existing = await this.getUserProfileFieldValue(userId, field.id); + const saved = existing + ? await this.updateUserProfileFieldValue(userId, field.id, { value: value as UserProfileFieldValue['value'] }) + : await this.createUserProfileFieldValue({ userId, fieldId: field.id, value: value as InsertUserProfileFieldValue['value'] }); + + await this.createUserProfileAuditLog({ + userId, + organizationId, + action: 'field.changed', + fieldId: field.id, + fieldName: field.name, + oldValue: existing?.value ?? null, + newValue: typeof value === 'string' ? value : value != null ? JSON.stringify(value) : null, + changedBy: null, + changedByName: 'Автоматизация', + metadata: { source: 'automation', taskId: taskId ?? null }, + }); + + // Для полей с историей дополнительно пишем числовое значение в user_field_history + if (field.type === 'history-number') { + const num = typeof value === 'number' ? value : parseFloat(String(value).replace(',', '.').trim()); + if (!Number.isFinite(num)) { + throw new Error(`Поле "${field.name}" имеет тип history-number: значение "${String(value)}" не является числом`); + } + await db.insert(userFieldHistory).values({ + organizationId, + userId, + fieldId: field.id, + value: String(num), + taskId: taskId ?? null, + }); + } + + return saved; + } + async bulkSaveUserProfileFields( organizationId: number, fieldIdsToDelete: number[], @@ -347,6 +413,49 @@ export class UserProfileStorage extends UsersStorage { return entry; } + // ========== FIELD HISTORY ========== + // Агрегация истории числового поля (history-number) по временным бакетам + // для графика в профиле пользователя. Пустые бакеты не возвращаются. + async getUserFieldHistoryPoints( + userId: number, + fieldId: number, + organizationId: number, + days: number, + bucket: 'day' | 'week' | 'month' + ): Promise<{ + points: { date: string; avg: number; count: number }[]; + overallAvg: number | null; + totalCount: number; + }> { + const pointsResult = await db.execute(sql` + SELECT to_char(date_trunc(${bucket}, created_at), 'YYYY-MM-DD') AS date, + AVG(value)::float8 AS avg, + COUNT(*)::int AS count + FROM user_field_history + WHERE user_id = ${userId} + AND field_id = ${fieldId} + AND organization_id = ${organizationId} + AND created_at >= now() - make_interval(days => ${days}) + GROUP BY 1 + ORDER BY 1 + `); + const totalsResult = await db.execute(sql` + SELECT AVG(value)::float8 AS overall_avg, COUNT(*)::int AS total_count + FROM user_field_history + WHERE user_id = ${userId} + AND field_id = ${fieldId} + AND organization_id = ${organizationId} + AND created_at >= now() - make_interval(days => ${days}) + `); + const points = pointsResult.rows as Array<{ date: string; avg: number; count: number }>; + const totals = (totalsResult.rows as Array<{ overall_avg: number | null; total_count: number }>)[0]; + return { + points, + overallAvg: totals?.overall_avg ?? null, + totalCount: totals?.total_count ?? 0, + }; + } + // ========== DETAIL ========== async getUserProfileDetail( userId: number, diff --git a/server/utils/user-field-value.ts b/server/utils/user-field-value.ts new file mode 100644 index 0000000..35a228c --- /dev/null +++ b/server/utils/user-field-value.ts @@ -0,0 +1,29 @@ +/** + * Хелперы для полей типа `user` (пользователь/роль) на сервере. + * + * Формат значения в task_field_values.value (jsonb): + * - одиночное: строка `user:` / `role:` или legacy-число (userId); + * - множественное (form_fields.allow_multiple): массив строк ["user:5", "role:2"]. + * + * Одиночные значения читаются как массив из одного элемента (обратная совместимость). + * Аналог клиентского client/src/lib/userFieldValue.ts. + */ + +/** Нормализует сырое значение user-поля в массив строк-элементов. */ +export function parseUserFieldEntries(value: unknown): string[] { + if (value === null || value === undefined || value === '') return []; + if (Array.isArray(value)) { + return value + .map((v) => (v === null || v === undefined ? '' : String(v))) + .filter((v) => v !== ''); + } + if (typeof value === 'string' && value.startsWith('[')) { + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) return parseUserFieldEntries(parsed); + } catch { + // не JSON — обрабатываем как одиночное значение + } + } + return [String(value)]; +} diff --git a/server/workers/automation-worker-code.ts b/server/workers/automation-worker-code.ts index cb02212..e04d314 100644 --- a/server/workers/automation-worker-code.ts +++ b/server/workers/automation-worker-code.ts @@ -61,6 +61,13 @@ const ctx = { list: function() { return callStorage('getUsersByOrganization', [organizationId]); }, update: function(userId, updates) { return callStorage('updateUser', [userId, updates]); }, updateStatus: function(userId, statusId) { return callStorage('updateUser', [userId, { statusId }]); }, + setFieldValue: function(userId, fieldCodeOrId, value) { + // taskId подставляем из данных триггера, чтобы оценки history-number были привязаны к задаче + const taskId = triggerData && typeof triggerData === 'object' && typeof triggerData.taskId === 'number' + ? triggerData.taskId + : null; + return callStorage('setUserProfileFieldValueFromAutomation', [userId, fieldCodeOrId, value, organizationId, taskId]); + }, }, tasks: { list: function(formId) { return callStorage('getTasksByForm', [formId, organizationId]); }, diff --git a/shared/field-conditions.ts b/shared/field-conditions.ts index 5c5cd33..6c20b12 100644 --- a/shared/field-conditions.ts +++ b/shared/field-conditions.ts @@ -56,6 +56,46 @@ export function isFieldValueEmpty(value: unknown, fieldType?: string): boolean { return false; } +/** + * Отображаемый текст одного элемента user-поля (`user:` / `role:` / legacy-число). + */ +function resolveUserEntryText(entry: string, context: FieldConditionContext): string { + if (entry.startsWith("user:")) { + const uid = Number(entry.replace("user:", "")); + const user = context.users?.find((u) => u.id === uid); + if (user) { + return ( + user.fullName || + `${user.firstName || ""} ${user.middleName || ""} ${user.lastName || ""}`.trim() || + user.email || + entry + ); + } + return entry; + } + if (entry.startsWith("role:")) { + const rid = Number(entry.replace("role:", "")); + const role = context.roles?.find((r) => r.id === rid); + if (role) { + return `Роль: ${role.name || entry}`; + } + return entry; + } + const uid = Number(entry); + if (!isNaN(uid)) { + const user = context.users?.find((u) => u.id === uid); + if (user) { + return ( + user.fullName || + `${user.firstName || ""} ${user.middleName || ""} ${user.lastName || ""}`.trim() || + user.email || + entry + ); + } + } + return entry; +} + /** * Преобразует хранимое значение поля в строку для поиска (отображаемый текст). */ @@ -75,40 +115,23 @@ export function resolveFieldDisplayText( switch (fieldType) { case "user": { - if (rawStr.startsWith("user:")) { - const uid = Number(rawStr.replace("user:", "")); - const user = context.users?.find((u) => u.id === uid); - if (user) { - return ( - user.fullName || - `${user.firstName || ""} ${user.middleName || ""} ${user.lastName || ""}`.trim() || - user.email || - rawStr - ); - } - return rawStr; - } - if (rawStr.startsWith("role:")) { - const rid = Number(rawStr.replace("role:", "")); - const role = context.roles?.find((r) => r.id === rid); - if (role) { - return `Роль: ${role.name || rawStr}`; - } - return rawStr; - } - const uid = Number(rawStr); - if (!isNaN(uid)) { - const user = context.users?.find((u) => u.id === uid); - if (user) { - return ( - user.fullName || - `${user.firstName || ""} ${user.middleName || ""} ${user.lastName || ""}`.trim() || - user.email || - rawStr - ); + // Мультивыбор (allow_multiple): массив ["user:5", "role:2"] — имена через запятую + let parsedVal: unknown = value; + if (typeof value === "string" && value.startsWith("[")) { + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) parsedVal = parsed; + } catch { + parsedVal = value; } } - return rawStr; + if (Array.isArray(parsedVal)) { + return parsedVal + .map((entry) => resolveUserEntryText(safeString(entry), context)) + .filter(Boolean) + .join(", "); + } + return resolveUserEntryText(rawStr, context); } case "task": { diff --git a/shared/schema.ts b/shared/schema.ts index d806472..573baef 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -326,6 +326,39 @@ export const userProfileFieldValuesRelations = relations(userProfileFieldValues, }), })); +// User field history table (numeric history of user profile fields with type history-number) +export const userFieldHistory = pgTable("user_field_history", { + id: serial("id").primaryKey(), + organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + fieldId: integer("field_id").notNull().references(() => userProfileFields.id, { onDelete: "cascade" }), + value: numeric("value").notNull(), + taskId: integer("task_id").references(() => tasks.id, { onDelete: "set null" }), // задача, из которой выставлена оценка (может быть null) + createdAt: timestamp("created_at").defaultNow(), +}, (table) => ({ + userFieldCreatedIdx: index("user_field_history_user_field_created_idx").on(table.userId, table.fieldId, table.createdAt), + orgIdx: index("user_field_history_org_idx").on(table.organizationId), +})); + +export const userFieldHistoryRelations = relations(userFieldHistory, ({ one }) => ({ + organization: one(organizations, { + fields: [userFieldHistory.organizationId], + references: [organizations.id], + }), + user: one(users, { + fields: [userFieldHistory.userId], + references: [users.id], + }), + field: one(userProfileFields, { + fields: [userFieldHistory.fieldId], + references: [userProfileFields.id], + }), + task: one(tasks, { + fields: [userFieldHistory.taskId], + references: [tasks.id], + }), +})); + // User profile audit log table (history of user profile changes) export const userProfileAuditLog = pgTable("user_profile_audit_log", { id: serial("id").primaryKey(), @@ -1049,6 +1082,11 @@ export const insertUserProfileAuditLogSchema = createInsertSchema(userProfileAud createdAt: true, }); +export const insertUserFieldHistorySchema = createInsertSchema(userFieldHistory).omit({ + id: true, + createdAt: true, +}); + export const insertFormSchema = createInsertSchema(forms).omit({ id: true, createdAt: true, @@ -1336,10 +1374,12 @@ export type UserProfileTab = typeof userProfileTabs.$inferSelect; export type UserProfileField = typeof userProfileFields.$inferSelect; export type UserProfileFieldValue = typeof userProfileFieldValues.$inferSelect; export type UserProfileAuditLog = typeof userProfileAuditLog.$inferSelect; +export type UserFieldHistory = typeof userFieldHistory.$inferSelect; export type InsertUserProfileTab = z.infer; export type InsertUserProfileField = z.infer; export type InsertUserProfileFieldValue = z.infer; export type InsertUserProfileAuditLog = z.infer; +export type InsertUserFieldHistory = z.infer; export type InsertForm = z.infer; export type InsertFormTab = z.infer; export type InsertFormField = z.infer;