fix(history): московское время и ФИО вместо user ID в истории задачи
This commit is contained in:
@@ -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<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 '—';
|
||||
|
||||
// Резолв 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<string, unknown>).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 <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 meta = (entry.metadata || {}) as Record<string, unknown>;
|
||||
|
||||
@@ -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<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) {
|
||||
return (
|
||||
<div className="space-y-2 p-1">
|
||||
@@ -144,7 +224,7 @@ export function TaskHistory({ taskId }: Props) {
|
||||
<ActionIcon action={entry.action} fieldName={entry.fieldName} />
|
||||
</div>
|
||||
<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">
|
||||
{entry.botId != null && (
|
||||
<Bot className="w-3 h-3 text-muted-foreground" aria-label="Действие бота" />
|
||||
|
||||
Reference in New Issue
Block a user