diff --git a/client/src/components/TaskHistory.tsx b/client/src/components/TaskHistory.tsx index a1b3f6c..3cf4fdd 100644 --- a/client/src/components/TaskHistory.tsx +++ b/client/src/components/TaskHistory.tsx @@ -19,20 +19,84 @@ interface AuditEntry { createdAt: string; } +interface UserSummary { + id: number; + firstName?: string | null; + lastName?: string | null; + middleName?: string | null; + email?: string; +} + interface Props { taskId: number; } -function formatValue(val: unknown): string { +// Определяем ISO-дату (YYYY-MM-DDTHH:mm:ss.sssZ или YYYY-MM-DDTHH:mm:ssZ) +function isISODateString(val: unknown): val is string { + if (typeof val !== 'string') return false; + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(val); +} + +// Форматируем ISO-дату в московское время +function formatISODateToMoscow(iso: string): string { + try { + const d = new Date(iso); + return d.toLocaleString('ru-RU', { + timeZone: 'Europe/Moscow', + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return iso; + } +} + +// Резолв user:17 или 17 в ФИО +function resolveUserName(val: unknown, usersMap?: Map): string | null { + if (usersMap === undefined) return null; + + let userId: number | null = null; + if (typeof val === 'number') { + userId = val; + } else if (typeof val === 'string') { + // Формат user:17 или просто число в строке + const match = val.match(/^user:(\d+)$/); + if (match) { + userId = parseInt(match[1], 10); + } else if (/^\d+$/.test(val)) { + userId = parseInt(val, 10); + } + } + + if (userId !== null && usersMap.has(userId)) { + return usersMap.get(userId)!; + } + return null; +} + +function formatValue(val: unknown, usersMap?: Map): string { if (val === null || val === undefined || val === '') return '—'; + + // Резолв user ID в ФИО + const userName = resolveUserName(val, usersMap); + if (userName !== null) return userName; + + // Форматируем ISO-даты в московское время + if (isISODateString(val)) { + return formatISODateToMoscow(val); + } + if (Array.isArray(val)) { if (val.length === 0) return '—'; - return val.map(item => formatValue(item)).join(', '); + return val.map(item => formatValue(item, usersMap)).join(', '); } if (typeof val === 'object') { const entries = Object.entries(val as Record).filter(([, v]) => v !== null && v !== undefined && v !== ''); if (entries.length === 0) return '—'; - return entries.map(([k, v]) => `${k}: ${formatValue(v)}`).join(', '); + return entries.map(([k, v]) => `${k}: ${formatValue(v, usersMap)}`).join(', '); } return String(val); } @@ -40,6 +104,7 @@ function formatValue(val: unknown): string { function formatDate(iso: string): string { const d = new Date(iso); return d.toLocaleString('ru-RU', { + timeZone: 'Europe/Moscow', day: '2-digit', month: '2-digit', year: 'numeric', @@ -61,7 +126,7 @@ function ActionIcon({ action, fieldName }: { action: string; fieldName?: string return ; } -function describeAction(entry: AuditEntry): string { +function describeAction(entry: AuditEntry, usersMap?: Map): string { const label = entry.fieldName || 'Поле'; const meta = (entry.metadata || {}) as Record; @@ -69,7 +134,7 @@ function describeAction(entry: AuditEntry): string { case 'task.created': return 'Задача создана'; case 'status.changed': - return `Статус: ${formatValue(entry.oldValue)} → ${formatValue(entry.newValue)}`; + return `Статус: ${formatValue(entry.oldValue, usersMap)} → ${formatValue(entry.newValue, usersMap)}`; case 'transition_approved': { const statusName = meta.statusName ? String(meta.statusName) : '?'; const roleName = meta.roleName ? String(meta.roleName) : null; @@ -86,10 +151,10 @@ function describeAction(entry: AuditEntry): string { // Per-column diff: prefer resolved display labels stored in metadata const oldStr = meta.displayOldValue !== undefined && meta.displayOldValue !== null ? String(meta.displayOldValue) - : formatValue(entry.oldValue); + : formatValue(entry.oldValue, usersMap); const newStr = meta.displayNewValue !== undefined && meta.displayNewValue !== null ? String(meta.displayNewValue) - : formatValue(entry.newValue); + : formatValue(entry.newValue, usersMap); if (oldStr === '—' && newStr === '—') return label; return `${label}: ${oldStr} → ${newStr}`; } @@ -104,6 +169,21 @@ export function TaskHistory({ taskId }: Props) { staleTime: 0, }); + // Загружаем пользователей для резолва user ID в ФИО + const { data: usersData } = useQuery<{ users: UserSummary[] }>({ + queryKey: ['/api/users/list'], + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); + + const usersMap = new Map(); + if (usersData?.users) { + for (const u of usersData.users) { + const name = [u.firstName, u.middleName, u.lastName].filter(Boolean).join(' ').trim() || u.email || `ID ${u.id}`; + usersMap.set(u.id, name); + } + } + if (isLoading) { return (
@@ -144,7 +224,7 @@ export function TaskHistory({ taskId }: Props) {
-

{describeAction(entry)}

+

{describeAction(entry, usersMap)}

{entry.botId != null && (