fix(history): московское время и ФИО вместо user ID в истории задачи
This commit is contained in:
@@ -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="Действие бота" />
|
||||||
|
|||||||
Reference in New Issue
Block a user