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:
@@ -727,6 +727,7 @@ export function CreateTaskFormContent({
|
||||
<UserFieldSelect
|
||||
formField={formField}
|
||||
users={users}
|
||||
roles={orgRoles}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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(' ')
|
||||
|
||||
@@ -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]">
|
||||
|
||||
97
client/src/components/fields/editors/UserRoleMultiSelect.tsx
Normal file
97
client/src/components/fields/editors/UserRoleMultiSelect.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
133
client/src/components/user/FieldHistoryDialog.tsx
Normal file
133
client/src/components/user/FieldHistoryDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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': {
|
||||
|
||||
43
client/src/lib/userFieldValue.ts
Normal file
43
client/src/lib/userFieldValue.ts
Normal 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;
|
||||
}
|
||||
@@ -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 — ежедневно в заданное время по МСК (настройка: время запуска)
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Настройки формулы */}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
17
migrations/0078_user_field_history.sql
Normal file
17
migrations/0078_user_field_history.sql
Normal 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);
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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:')) {
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: 'Переход выполнен успешно',
|
||||
|
||||
@@ -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,
|
||||
|
||||
29
server/utils/user-field-value.ts
Normal file
29
server/utils/user-field-value.ts
Normal 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)];
|
||||
}
|
||||
@@ -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]); },
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user