fix(history): московское время и ФИО вместо user ID в истории задачи

This commit is contained in:
2026-09-07 16:55:51 +03:00
parent 9faef8fcf5
commit a2b231d876

View File

@@ -19,20 +19,84 @@ interface AuditEntry {
createdAt: string; createdAt: string;
} }
interface UserSummary {
id: number;
firstName?: string | null;
lastName?: string | null;
middleName?: string | null;
email?: string;
}
interface Props { interface Props {
taskId: number; 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<number, string>): 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<number, string>): string {
if (val === null || val === undefined || val === '') return '—'; 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 (Array.isArray(val)) {
if (val.length === 0) return '—'; if (val.length === 0) return '—';
return val.map(item => formatValue(item)).join(', '); return val.map(item => formatValue(item, usersMap)).join(', ');
} }
if (typeof val === 'object') { if (typeof val === 'object') {
const entries = Object.entries(val as Record<string, unknown>).filter(([, v]) => v !== null && v !== undefined && v !== ''); const entries = Object.entries(val as Record<string, unknown>).filter(([, v]) => v !== null && v !== undefined && v !== '');
if (entries.length === 0) return '—'; 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); return String(val);
} }
@@ -40,6 +104,7 @@ function formatValue(val: unknown): string {
function formatDate(iso: string): string { function formatDate(iso: string): string {
const d = new Date(iso); const d = new Date(iso);
return d.toLocaleString('ru-RU', { return d.toLocaleString('ru-RU', {
timeZone: 'Europe/Moscow',
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
year: 'numeric', year: 'numeric',
@@ -61,7 +126,7 @@ function ActionIcon({ action, fieldName }: { action: string; fieldName?: string
return <Clock className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />; return <Clock className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />;
} }
function describeAction(entry: AuditEntry): string { function describeAction(entry: AuditEntry, usersMap?: Map<number, string>): string {
const label = entry.fieldName || 'Поле'; const label = entry.fieldName || 'Поле';
const meta = (entry.metadata || {}) as Record<string, unknown>; const meta = (entry.metadata || {}) as Record<string, unknown>;
@@ -69,7 +134,7 @@ function describeAction(entry: AuditEntry): string {
case 'task.created': case 'task.created':
return 'Задача создана'; return 'Задача создана';
case 'status.changed': case 'status.changed':
return `Статус: ${formatValue(entry.oldValue)} → ${formatValue(entry.newValue)}`; return `Статус: ${formatValue(entry.oldValue, usersMap)} → ${formatValue(entry.newValue, usersMap)}`;
case 'transition_approved': { case 'transition_approved': {
const statusName = meta.statusName ? String(meta.statusName) : '?'; const statusName = meta.statusName ? String(meta.statusName) : '?';
const roleName = meta.roleName ? String(meta.roleName) : null; 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 // Per-column diff: prefer resolved display labels stored in metadata
const oldStr = meta.displayOldValue !== undefined && meta.displayOldValue !== null const oldStr = meta.displayOldValue !== undefined && meta.displayOldValue !== null
? String(meta.displayOldValue) ? String(meta.displayOldValue)
: formatValue(entry.oldValue); : formatValue(entry.oldValue, usersMap);
const newStr = meta.displayNewValue !== undefined && meta.displayNewValue !== null const newStr = meta.displayNewValue !== undefined && meta.displayNewValue !== null
? String(meta.displayNewValue) ? String(meta.displayNewValue)
: formatValue(entry.newValue); : formatValue(entry.newValue, usersMap);
if (oldStr === '—' && newStr === '—') return label; if (oldStr === '—' && newStr === '—') return label;
return `${label}: ${oldStr} → ${newStr}`; return `${label}: ${oldStr} → ${newStr}`;
} }
@@ -104,6 +169,21 @@ export function TaskHistory({ taskId }: Props) {
staleTime: 0, 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<number, string>();
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) { if (isLoading) {
return ( return (
<div className="space-y-2 p-1"> <div className="space-y-2 p-1">
@@ -144,7 +224,7 @@ export function TaskHistory({ taskId }: Props) {
<ActionIcon action={entry.action} fieldName={entry.fieldName} /> <ActionIcon action={entry.action} fieldName={entry.fieldName} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-xs text-foreground leading-tight break-words">{describeAction(entry)}</p> <p className="text-xs text-foreground leading-tight break-words">{describeAction(entry, usersMap)}</p>
<div className="flex items-center gap-1.5 mt-0.5"> <div className="flex items-center gap-1.5 mt-0.5">
{entry.botId != null && ( {entry.botId != null && (
<Bot className="w-3 h-3 text-muted-foreground" aria-label="Действие бота" /> <Bot className="w-3 h-3 text-muted-foreground" aria-label="Действие бота" />