feat: рейтинговые автоматизации, мультивыбор user/role-полей, график history-number

Инфраструктура автоматизаций:
- триггер task.status_changed оживлён: вызов из executeTaskTransition (fire-and-forget) + фильтр triggerConfig.statusId
- ctx.users.setFieldValue(userId, fieldCodeOrId, value): запись user_profile_field_values с аудитом; для history-number — append в user_field_history (миграция 0078)
- документация sandbox обновлена (Automations.tsx, mcp.ts, AGENTS.md) по критическому правилу 2

Мультивыбор пользователей и ролей в user-поле:
- чекбокс «Множественный выбор (пользователи и роли)» в настройках поля (form_fields.allow_multiple)
- значение — массив ['user:ID','role:ID'], обратная совместимость с одиночными/legacy значениями
- UserRoleMultiSelect в карточке задачи, реестре и диалоге создания (роли добавлены и в одиночный сценарий создания)
- синк в task_assignees итерирует массив; role:ID раскрывается в членов роли; PATCH single — diff старого/нового значений
- отображение массивов: реестр, title-шаблоны, условия полей, MCP update_task_fields
- список орг-ролей (/api/roles) загружается всем пользователям (сервер и так был открыт) — роли доступны не только админам

График history-number в профиле:
- GET /api/users/:id/field-history/:fieldId?period=week|month|quarter|year (агрегация AVG по бакетам)
- диалог с LineChart (recharts) и переключателем периодов по клику на иконку у поля
This commit is contained in:
2026-09-04 19:39:10 +03:00
parent 013e97c8d3
commit 4d65049e02
27 changed files with 983 additions and 112 deletions

View File

@@ -727,6 +727,7 @@ export function CreateTaskFormContent({
<UserFieldSelect
formField={formField}
users={users}
roles={orgRoles}
value={field.value}
onChange={field.onChange}
/>

View File

@@ -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 (
<div className="w-full min-w-[200px]" onClick={(e) => e.stopPropagation()}>
<UserRoleMultiSelect
size="cell"
users={filteredUsers}
roles={filteredRoles}
value={editValue}
onChange={(val) => setEditValue(val)}
/>
</div>
);
}
const stringValue = (() => {
if (editValue === null || editValue === undefined || editValue === '') return '__none__';
if (typeof editValue === 'number') return `user:${editValue}`;

View File

@@ -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<string, unknown>[]).map(f => f?.name ?? '').join(' ')

View File

@@ -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<FieldEditorProps & { autoOpen?: boolean }> = ({ 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<FieldEditorProps & { autoOpen?: boolean }>
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 (
<UserRoleMultiSelect
users={multiUsers}
roles={multiRoles}
value={value}
onChange={onChange}
placeholder={field.placeholder || 'Выберите пользователей и роли...'}
size="detail"
/>
);
}
return (
<Select value={stringValue} onValueChange={handleChange} defaultOpen={autoOpen}>
<SelectTrigger className="w-auto min-w-[200px]">

View File

@@ -0,0 +1,97 @@
import React, { useMemo } from 'react';
import { SearchableMultiSelect } from '../SearchableMultiSelect';
import type { FieldControlSize } from '../field-styles';
import { parseUserFieldValue, normalizeUserFieldEntry } from '@/lib/userFieldValue';
interface UserOption {
id: number;
email?: string;
firstName?: string | null;
lastName?: string | null;
middleName?: string | null;
fullName?: string | null;
}
interface RoleOption {
id: number;
name?: string;
}
function formatUserName(user: UserOption): string {
return (
user.fullName ||
`${user.firstName || ''} ${user.middleName || ''} ${user.lastName || ''}`.trim() ||
user.email ||
`ID ${user.id}`
);
}
interface UserRoleMultiSelectProps {
/** Списки опций (уже отфильтрованные вызывающим кодом с учётом options.userFilter) */
users: UserOption[];
roles?: RoleOption[];
/** Сырое значение поля: массив / одиночная строка / legacy-число */
value: unknown;
/** Массив строк `user:<id>` / `role:<id>` или null при пустом выборе */
onChange: (value: string[] | null) => void;
placeholder?: string;
disabled?: boolean;
size?: FieldControlSize;
dataTestid?: string;
}
/**
* Множественный выбор пользователей и ролей для поля типа `user` (allowMultiple).
* Значение — массив строк ["user:5", "role:2"].
*/
export const UserRoleMultiSelect: React.FC<UserRoleMultiSelectProps> = ({
users,
roles,
value,
onChange,
placeholder = 'Выберите пользователей и роли...',
disabled,
size = 'form',
dataTestid,
}) => {
const options = useMemo(() => {
const opts = [
...(users || []).map((u) => ({ value: `user:${u.id}`, label: formatUserName(u) })),
...(roles || []).map((r) => ({ value: `role:${r.id}`, label: `Роль: ${r.name || r.id}` })),
];
// Текущие значения включаем всегда, даже если они не проходят userFilter,
// иначе выбранные ранее элементы пропадут из списка при следующем открытии
const present = new Set(opts.map((o) => o.value));
for (const entry of parseUserFieldValue(value)) {
const normalized = normalizeUserFieldEntry(entry);
if (normalized && !present.has(normalized)) {
opts.push({ value: normalized, label: normalized });
}
}
return opts;
}, [users, roles, value]);
const selected = useMemo(() => {
const set = new Set<string>();
for (const entry of parseUserFieldValue(value)) {
const normalized = normalizeUserFieldEntry(entry);
if (normalized) set.add(normalized);
}
return set;
}, [value]);
return (
<SearchableMultiSelect
options={options}
selected={selected}
onChange={(next) => {
const arr = Array.from(next);
onChange(arr.length > 0 ? arr : null);
}}
placeholder={placeholder}
disabled={disabled}
size={size}
dataTestid={dataTestid}
/>
);
};

View File

@@ -1,5 +1,6 @@
import React from 'react';
import type { FieldRendererProps } from '../FieldRegistry';
import { parseUserFieldValue } from '@/lib/userFieldValue';
function formatUserName(user: { fullName?: string | null; firstName?: string | null; lastName?: string | null; middleName?: string | null; email?: string | null }) {
return user.fullName || `${user.firstName || ''} ${user.middleName || ''} ${user.lastName || ''}`.trim() || user.email || '';
@@ -10,6 +11,14 @@ export const UserRoleRenderer: React.FC<FieldRendererProps> = ({ value, users, r
return <span>—</span>;
}
// Мультивыбор (allowMultiple): массив ["user:5", "role:2"] — имена через запятую
if (Array.isArray(value)) {
const entries = parseUserFieldValue(value);
if (entries.length === 0) return <span>—</span>;
const parts = entries.map((entry) => resolveEntry(entry, users, roles));
return <span>{parts.join(', ')}</span>;
}
// Legacy: числовой userId
if (typeof value === 'number') {
const user = users?.find((u) => u.id === value);
@@ -42,3 +51,26 @@ export const UserRoleRenderer: React.FC<FieldRendererProps> = ({ value, users, r
return <span>{str}</span>;
};
function resolveEntry(
entry: string,
users?: Array<{ id: number; fullName?: string | null; firstName?: string | null; lastName?: string | null; middleName?: string | null; email?: string | null }>,
roles?: Array<{ id: number; name?: string }>
): string {
if (entry.startsWith('role:')) {
const roleId = Number(entry.replace('role:', ''));
const role = roles?.find((r) => r.id === roleId);
return role ? `Роль: ${role.name}` : entry;
}
if (entry.startsWith('user:')) {
const userId = Number(entry.replace('user:', ''));
const user = users?.find((u) => u.id === userId);
return user ? formatUserName(user) : entry;
}
const userId = Number(entry);
if (!isNaN(userId)) {
const user = users?.find((u) => u.id === userId);
return user ? formatUserName(user) : entry;
}
return entry;
}

View File

@@ -1283,14 +1283,15 @@ export function FieldValueRow({
</div>
);
}
// Пользователь/роль: сохранение сразу по выбору (radix Select)
// Пользователь/роль: сохранение сразу по выбору (radix Select);
// мультивыбор — сохранение по каждому переключению, выход — клик вне / Esc
if (field.type === 'user') {
return (
<div onClick={e => e.stopPropagation()}>
<UserRoleEditor
field={field as any}
value={editValue as string | number | null | undefined}
onChange={(val) => commitValue(val)}
onChange={(val) => commitValue(val, { close: !field.allowMultiple })}
users={users}
roles={roles}
autoOpen

View File

@@ -8,6 +8,7 @@ import { useToast } from '@/hooks/use-toast';
import { apiRequest, queryClient } from '@/lib/queryClient';
import { normalizeSelectOptions } from '@/lib/fieldOptions';
import { fieldControlClass, type FieldControlSize } from '@/components/fields/field-styles';
import { UserRoleMultiSelect } from '@/components/fields/editors/UserRoleMultiSelect';
import { CreateTaskDialog } from '@/components/CreateTaskDialog';
export interface FormFieldData {
@@ -213,14 +214,16 @@ export function TaskFieldSelect({ formField, value, onChange, showCreateButton =
export function UserFieldSelect({
formField,
users,
roles,
value,
onChange,
size = 'form'
}: {
formField: FormFieldData;
users: UserData[];
roles?: Array<{ id: number; name?: string }>;
value: any;
onChange: (value: string) => void;
onChange: (value: any) => void;
size?: FieldControlSize;
}) {
const [searchTerm, setSearchTerm] = useState('');
@@ -235,6 +238,12 @@ export function UserFieldSelect({
return users.filter((u) => userFilter.userIds!.includes(u.id));
}, [users, userFilter]);
const allowedRoles = useMemo(() => {
if (!roles) return [];
if (!userFilter?.roleIds || userFilter.roleIds.length === 0) return roles;
return roles.filter((r) => userFilter.roleIds!.includes(r.id));
}, [roles, userFilter]);
const selectedUser = allowedUsers.find(u => u.id.toString() === value?.toString());
const sortedUsers = useMemo(() => {
@@ -258,6 +267,21 @@ export function UserFieldSelect({
setIsOpen(false);
};
// Множественный выбор (allowMultiple): пользователи и роли, значение — массив ["user:5", "role:2"]
if (formField.allowMultiple) {
return (
<UserRoleMultiSelect
users={allowedUsers}
roles={allowedRoles}
value={value}
onChange={(val) => onChange(val ?? '')}
placeholder={formField.placeholder || 'Выберите пользователей и роли...'}
size={size}
dataTestid={`select-user-${formField.code}`}
/>
);
}
return (
<Popover open={isOpen} onOpenChange={setIsOpen} modal={false}>
<PopoverAnchor asChild>

View File

@@ -0,0 +1,133 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
import { Loader2, TrendingUp } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
import { apiRequest } from '@/lib/queryClient';
type HistoryPeriod = 'week' | 'month' | 'quarter' | 'year';
interface FieldHistoryResponse {
success: boolean;
field: { id: number; name: string };
period: HistoryPeriod;
points: { date: string; avg: number; count: number }[];
overallAvg: number | null;
totalCount: number;
}
const PERIODS: { value: HistoryPeriod; label: string }[] = [
{ value: 'week', label: 'Неделя' },
{ value: 'month', label: 'Месяц' },
{ value: 'quarter', label: 'Квартал' },
{ value: 'year', label: 'Год' },
];
const chartConfig = {
avg: { label: 'Среднее', color: 'hsl(var(--primary))' },
} satisfies ChartConfig;
function formatBucketDate(date: string, period: HistoryPeriod): string {
const [y, m, d] = date.split('-').map(Number);
if (period === 'year') return `${String(m).padStart(2, '0')}.${y}`;
return `${String(d).padStart(2, '0')}.${String(m).padStart(2, '0')}`;
}
function formatAvg(value: number): string {
return value.toLocaleString('ru-RU', { maximumFractionDigits: 2 });
}
export function FieldHistoryDialog({
userId,
fieldId,
fieldName,
open,
onOpenChange,
}: {
userId: number;
fieldId: number;
fieldName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [period, setPeriod] = useState<HistoryPeriod>('month');
const { data, isLoading } = useQuery<FieldHistoryResponse>({
queryKey: ['/api/users', userId, 'field-history', fieldId, period],
queryFn: async () => {
const res = await apiRequest('GET', `/api/users/${userId}/field-history/${fieldId}?period=${period}`);
return (await res.json()) as FieldHistoryResponse;
},
enabled: open,
});
const points = data?.points ?? [];
const hasData = (data?.totalCount ?? 0) > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{fieldName}: история</DialogTitle>
</DialogHeader>
<Tabs value={period} onValueChange={(v) => setPeriod(v as HistoryPeriod)}>
<TabsList className="grid w-full grid-cols-4">
{PERIODS.map((p) => (
<TabsTrigger key={p.value} value={p.value}>
{p.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{isLoading ? (
<div className="flex items-center justify-center h-[260px]">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : !hasData ? (
<div className="flex flex-col items-center justify-center h-[260px] text-muted-foreground">
<TrendingUp className="w-8 h-8 mb-2 opacity-50" />
<p className="text-sm">Пока нет оценок</p>
</div>
) : (
<>
<ChartContainer config={chartConfig} className="h-[260px] w-full aspect-auto">
<LineChart data={points} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickFormatter={(d: string) => formatBucketDate(d, period)}
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<YAxis tickLine={false} axisLine={false} width={40} domain={['auto', 'auto']} />
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(label) => formatBucketDate(String(label), period)}
formatter={(value) => formatAvg(Number(value))}
/>
}
/>
<Line
dataKey="avg"
type="monotone"
stroke="var(--color-avg)"
strokeWidth={2}
dot={{ r: 3 }}
/>
</LineChart>
</ChartContainer>
<p className="text-sm text-muted-foreground text-center">
Среднее за период: {data?.overallAvg != null ? formatAvg(data.overallAvg) : '—'} · оценок: {data?.totalCount ?? 0}
</p>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -5,6 +5,7 @@
*/
import { getSelectOptionLabel } from './fieldOptions';
import { parseUserFieldValue } from './userFieldValue';
export interface FieldDisplayContext {
users?: Array<{
@@ -38,6 +39,45 @@ function parseJson<T>(value: unknown): T | undefined {
return undefined;
}
/** Отображаемое имя для одного элемента user-поля (`user:<id>` / `role:<id>` / legacy-число). */
function resolveUserEntryDisplay(entry: string, context: FieldDisplayContext): 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;
}
// Legacy: просто числовой ID
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;
}
/**
* Преобразует хранимое значение поля в строку для отображения.
*/
@@ -57,41 +97,14 @@ export function resolveFieldDisplayValue(
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;
// Мультивыбор (allowMultiple): массив ["user:5", "role:2"] — имена через запятую
if (Array.isArray(value)) {
return parseUserFieldValue(value)
.map((entry) => resolveUserEntryDisplay(entry, context))
.filter(Boolean)
.join(', ');
}
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;
}
// Legacy: просто числовой ID
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
);
}
}
return rawStr;
return resolveUserEntryDisplay(rawStr, context);
}
case 'role': {

View File

@@ -0,0 +1,43 @@
/**
* Хелперы для полей типа `user` (пользователь/роль).
*
* Формат значения в task_field_values.value (jsonb):
* - одиночное: строка `user:<id>` / `role:<id>` или legacy-число (userId);
* - множественное (form_fields.allow_multiple): массив строк ["user:5", "role:2"].
*
* Одиночные значения читаются как массив из одного элемента (обратная совместимость).
*/
/** Нормализует сырое значение user-поля в массив строк `user:<id>` / `role:<id>` / legacy-число как строка. */
export function parseUserFieldValue(raw: unknown): string[] {
if (raw === null || raw === undefined || raw === '') return [];
if (Array.isArray(raw)) {
return raw
.map((v) => (v === null || v === undefined ? '' : String(v)))
.filter((v) => v !== '');
}
// Значение может прийти JSON-строкой (например, из офлайн-кэша)
if (typeof raw === 'string' && raw.startsWith('[')) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parseUserFieldValue(parsed);
} catch {
// не JSON — обрабатываем как одиночное значение
}
}
return [String(raw)];
}
/** Признак мультивыбора для поля типа `user`. */
export function isMultiUserField(field: { allowMultiple?: boolean | null } | null | undefined): boolean {
return !!field?.allowMultiple;
}
/** Нормализует один элемент значения к виду `user:<id>` / `role:<id>` (legacy-число → `user:<id>`). */
export function normalizeUserFieldEntry(entry: string): string | null {
const str = String(entry);
if (str.startsWith('user:') || str.startsWith('role:')) return str;
const num = Number(str);
if (!isNaN(num)) return `user:${num}`;
return null;
}

View File

@@ -81,12 +81,17 @@ const CODE_TEMPLATE = `// ======================================================
// ctx.users.get(id) — получить пользователя
// ctx.users.update(id, updates) — обновить пользователя (поля firstName, lastName, position, statusId и др.)
// ctx.users.updateStatus(id, statusId) — сменить статус пользователя
// ctx.users.setFieldValue(id, fieldCodeOrId, value) — записать значение кастомного поля профиля
// пользователя (по id или code поля, с аудитом от имени «Автоматизация»). Для полей типа
// «history-number» значение дополнительно добавляется в историю user_field_history
// (с привязкой к задаче триггера); нечисловое значение вызовет ошибку автоматизации.
//
// Триггеры:
// manual — запуск вручную
// task.before_create — перед созданием задачи (можно отменить через ctx.result = { allow: false, error: '...' })
// task.created — после создания задачи
// task.status_changed — при изменении статуса задачи
// (triggerConfig: { formId?, statusId? }; statusId — срабатывать только при переходе В этот статус)
// qr.scan — при сканировании QR-кода в PWA (только офлайн-автоматизации)
// schedule — ежедневно в заданное время по МСК (triggerConfig: { time: 'HH:MM' })
//
@@ -111,6 +116,14 @@ for (const form of forms) {
const tasks = await ctx.tasks.list(form.id);
ctx.log(\`Форма "\${form.name}": \${tasks.length} задач\`);
}
// Пример для триггера task.status_changed — начислить рейтинг исполнителю
// при переходе задачи в нужный статус (statusId задаётся в настройках триггера):
// const { taskId, oldStatusId, newStatusId, task } = ctx.triggerData;
// ctx.log('Задача ' + taskId + ': статус ' + oldStatusId + ' -> ' + newStatusId);
// if (task.assignedTo) {
// await ctx.users.setFieldValue(task.assignedTo, 'rating', 5);
// }
`;
export function AutomationsContent() {
@@ -305,6 +318,9 @@ export function AutomationsContent() {
Пользователи:
ctx.users.list() / ctx.users.get(userId)
ctx.users.update(userId, updates) / ctx.users.updateStatus(userId, statusId)
ctx.users.setFieldValue(userId, fieldCodeOrId, value) — записать кастомное поле профиля
(с аудитом; для полей «history-number» значение также пишется в историю user_field_history,
нечисловое значение — ошибка автоматизации)
Логирование: ctx.log(msg)
@@ -312,7 +328,8 @@ export function AutomationsContent() {
manual — запуск вручную
task.before_create — перед созданием задачи (можно отменить через ctx.result = { allow: false, error: '...' })
task.created — после создания задачи
task.status_changed — при изменении статуса задачи
task.status_changed — при изменении статуса задачи (triggerConfig: { formId?, statusId? };
statusId — срабатывать только при переходе В этот статус)
qr.scan — при сканировании QR-кода в PWA
schedule — ежедневно в заданное время по МСК (настройка: время запуска)

View File

@@ -2490,19 +2490,29 @@ const FormEditor = () => {
{/* Настройки поля Пользователь/Роль */}
{field.type === 'user' && (
<UserFilterSettings
filter={(field.options as any)?.userFilter}
users={usersData?.users}
roles={rolesData}
onChange={(filter) =>
updateField(index, {
options: {
...field.options,
userFilter: filter,
},
})
}
/>
<>
<div className="mt-3 flex items-center space-x-2">
<Checkbox
checked={field.allowMultiple || false}
onCheckedChange={(checked) => updateField(index, { allowMultiple: !!checked })}
data-testid={`checkbox-field-allow-multiple-${index}`}
/>
<label className="text-sm font-medium">Множественный выбор (пользователи и роли)</label>
</div>
<UserFilterSettings
filter={(field.options as any)?.userFilter}
users={usersData?.users}
roles={rolesData}
onChange={(filter) =>
updateField(index, {
options: {
...field.options,
userFilter: filter,
},
})
}
/>
</>
)}
{/* Настройки формулы */}

View File

@@ -20,13 +20,14 @@ import { apiRequest, queryClient } from '@/lib/queryClient';
import { authService } from '@/services/auth.service';
import { normalizeSelectOptions } from '@/lib/fieldOptions';
import { SearchableMultiSelect } from '@/components/fields/SearchableMultiSelect';
import { ArrowLeft, Save, Loader2, User as UserIcon, Mail, Shield, Briefcase, FileText, History, Calendar, Clock, CheckCircle2, Circle, AlertCircle, UserCheck, Bell, WifiOff, Palette, Lock, MapPin } from 'lucide-react';
import { ArrowLeft, Save, Loader2, User as UserIcon, Mail, Shield, Briefcase, FileText, History, Calendar, Clock, CheckCircle2, Circle, AlertCircle, UserCheck, Bell, WifiOff, Palette, Lock, MapPin, TrendingUp } from 'lucide-react';
import { Link } from 'wouter';
import CompanyFieldInput from '@/components/CompanyFieldInput';
import GeoField from '@/components/GeoField';
import TableFieldSelect from '@/components/TableFieldSelect';
import TableFieldMultiSelect from '@/components/TableFieldMultiSelect';
import { ContactListEditor } from '@/components/user/ContactListEditor';
import { FieldHistoryDialog } from '@/components/user/FieldHistoryDialog';
import ContactField from '@/components/ContactField';
import { ProfileNotificationsTab } from '@/components/profile/ProfileNotificationsTab';
import { ProfileOfflineTab } from '@/components/profile/ProfileOfflineTab';
@@ -331,21 +332,65 @@ function HistoryTab({ auditLog }: { auditLog: AuditLogEntry[] }) {
);
}
// Поле history-number: обычный ввод значения + кнопка с графиком истории оценок.
// График доступен всегда (просмотр), редактирование значения — как у остальных полей.
function HistoryNumberFieldInput({
field,
value,
onChange,
userId,
}: {
field: UserProfileField;
value: any;
onChange: (val: any) => void;
userId: number;
}) {
const [historyOpen, setHistoryOpen] = useState(false);
return (
<div className="flex items-center gap-2">
<Input
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
required={field.isRequired}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
title="История оценок"
onClick={() => setHistoryOpen(true)}
>
<TrendingUp className="w-4 h-4" />
</Button>
<FieldHistoryDialog
userId={userId}
fieldId={field.id}
fieldName={field.name}
open={historyOpen}
onOpenChange={setHistoryOpen}
/>
</div>
);
}
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 (
<Input
value={value ?? ''}
@@ -354,6 +399,15 @@ function UserProfileFieldInput({
required={field.isRequired}
/>
);
case 'history-number':
return (
<HistoryNumberFieldInput
field={field}
value={value}
onChange={onChange}
userId={userId}
/>
);
case 'number':
return (
<Input
@@ -1252,6 +1306,7 @@ export default function UserProfilePage({ userId: userIdProp }: { userId?: numbe
value={customValues[field.id] ?? getFieldValue(field.id, data.fieldValues)}
onChange={(val) => setCustomValues((prev) => ({ ...prev, [field.id]: val }))}
users={allUsers}
userId={userId}
/>
</div>
))}

View File

@@ -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);

View File

@@ -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:<id> → все члены роли.
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

View File

@@ -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<string, unknown>;
if (Number(config.statusId) !== Number(data.newStatusId)) continue;
}
const { logs, error, result } = await runAutomationInWorker(
automation.code,
organizationId,

View File

@@ -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));

View File

@@ -345,6 +345,30 @@ export function registerTaskCrudListRoutes(router: ReturnType<typeof import("exp
}
});
// Отображаемое имя одного элемента user-поля (`user:<id>` / `role:<id>` / legacy-число)
function resolveUserColumnEntry(
entry: string,
userMap: Map<number, User>,
roleMap?: Map<number, { name?: string }>
): 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<number, unknown>; 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<typeof import("exp
const str = String(v);
switch (field?.type) {
case 'user': {
if (str.startsWith('user:')) {
const uid = Number(str.replace('user:', ''));
const u = userMap.get(uid);
return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : str;
// Мультивыбор (allow_multiple): массив ["user:5", "role:2"] — имена через запятую
let parsedUserVal: unknown = v;
if (typeof v === 'string' && v.startsWith('[')) {
try { parsedUserVal = JSON.parse(v); } catch { parsedUserVal = v; }
}
const uid = Number(str);
if (!isNaN(uid)) {
const u = userMap.get(uid);
return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : str;
if (Array.isArray(parsedUserVal)) {
return parsedUserVal
.map((entry) => resolveUserColumnEntry(String(entry), userMap, roleMap))
.filter(Boolean)
.join(', ');
}
return str;
return resolveUserColumnEntry(str, userMap, roleMap);
}
case 'role': {
if (str.startsWith('role:')) {

View File

@@ -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:<id> / legacy-число → сам пользователь;
* - role:<id> → все члены роли (как в автопереходах — storage.getRoleMembersByRole).
*/
async function resolveAssigneeUserIds(value: unknown, organizationId: number): Promise<number[]> {
const entries = parseUserFieldEntries(value);
const ids = new Set<number>();
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<typeof import("express").Router>): void {
router.get('/api/tasks/:id/field-values',
authenticateToken,
@@ -275,11 +301,12 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
}
// Sync task_assignees from user fields so tasks appear in inbox
// (одиночное значение и мультивыбор: user:<id> → пользователь, role:<id> → члены роли)
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<typeof import("expres
}
}
// Sync task_assignees from user field so task appears in inbox
// Sync task_assignees from user field so task appears in inbox.
// Семантика прежняя, обобщённая на массивы (мультивыбор): снимаем только тех,
// кто был в старом значении поля и кого нет в новом; role:<id> → члены роли.
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<typeof import("expres
tasksMinimalCache.invalidatePrefix(`tasks:${req.organizationId}:minimal:`);
// Sync task_assignees from user fields so tasks appear in inbox
// (одиночное значение и мультивыбор: user:<id> → пользователь, role:<id> → члены роли)
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(() => {});
}
}

View File

@@ -175,6 +175,60 @@ export function registerUserProfileRoutes(router: Router): void {
}
);
// === USER FIELD HISTORY (history-number) ===
const FIELD_HISTORY_PERIODS: Record<string, { days: number; bucket: 'day' | 'week' | 'month' }> = {
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,

View File

@@ -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: 'Переход выполнен успешно',

View File

@@ -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<UserProfileField | undefined> {
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<UserProfileFieldValue> {
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,

View File

@@ -0,0 +1,29 @@
/**
* Хелперы для полей типа `user` (пользователь/роль) на сервере.
*
* Формат значения в task_field_values.value (jsonb):
* - одиночное: строка `user:<id>` / `role:<id>` или 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)];
}

View File

@@ -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]); },

View File

@@ -56,6 +56,46 @@ export function isFieldValueEmpty(value: unknown, fieldType?: string): boolean {
return false;
}
/**
* Отображаемый текст одного элемента user-поля (`user:<id>` / `role:<id>` / 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": {

View File

@@ -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<typeof insertUserProfileTabSchema>;
export type InsertUserProfileField = z.infer<typeof insertUserProfileFieldSchema>;
export type InsertUserProfileFieldValue = z.infer<typeof insertUserProfileFieldValueSchema>;
export type InsertUserProfileAuditLog = z.infer<typeof insertUserProfileAuditLogSchema>;
export type InsertUserFieldHistory = z.infer<typeof insertUserFieldHistorySchema>;
export type InsertForm = z.infer<typeof insertFormSchema>;
export type InsertFormTab = z.infer<typeof insertFormTabSchema>;
export type InsertFormField = z.infer<typeof insertFormFieldSchema>;