food-market/src/food-market.web/src/pages/OrgAuditLogPage.tsx
nns 9bd4375ae4
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Public / Build + push Public (push) Waiting to run
Docker Public / Deploy Public on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
feat(s18): TODO cleanup — P0 race fix + helpTooltip + whats-new + contrast + currency + audit filters + notifications
7 пунктов cleanup-спринта:

1. P0: race в GenerateNumberAsync — DocumentNumberRetry helper с
   WithOrgAdvisoryLockAsync (pg_advisory_xact_lock per orgHash/docTypeHash)
   + SaveWithRetryAsync exponential backoff. RetailSalesController POST
   обёрнут в lock. После — 23505 errors 53% → 0 на k6 baseline-replay.

2. HelpTooltip integration — ListPageShell расширен `helpTopic` пропом.
   Применено к 4 страницам (Promotions, Loyalty×2, AuditLog) + inline
   на MoySkladImportPage.

3. WhatsNewBanner — узкий emerald-toast сверху AppLayout. Опрашивает
   /api/whats-new (staleTime=1h), сравнивает buildVersion с
   localStorage.fm.lastSeenBuildVersion. Dismiss сохраняет версию.

4. Color contrast sweep — text-slate-400 в body-text узлах (empty-state,
   table-cells, hints, help) заменён на text-slate-500 dark:text-slate-400.
   19 файлов. Иконки оставлены (decorative, не покрыты axe color-contrast).

5. useFormatCurrency() хук в lib/useFormatCurrency.ts. Берёт
   defaultCurrencySymbol из useOrgSettings + локаль из i18next.
   DashboardWidgets (TopProducts/RecentSales/Margin) переведены — `₸`
   больше не захардкоден.

6. Audit log UI filters — OrgAuditLogPage расширен полями «Кто»
   (Select сотрудников), «Дата с» / «по» (date-input'ы), кнопка
   «Сбросить фильтры». Backend уже умел эти параметры.

7. NotificationCenter — bell-icon в sidebar footer'е с unread badge,
   popover с 30 последних событий (Sale/Supply/LowStock через
   useNotificationsHub). Each item clickable → документ. In-memory.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 18:50:35 +05:00

183 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Field, Select } from '@/components/Field'
import type { PagedResult } from '@/lib/types'
interface AuditRow {
id: string
createdAt: string
userId: string | null
userName: string | null
action: string
entityType: string
entityId: string | null
changesJson: string
}
const ACTIONS = [
{ value: '', label: 'Все' },
{ value: 'create', label: 'Создание' },
{ value: 'update', label: 'Изменение' },
{ value: 'delete', label: 'Удаление' },
]
const ENTITY_TYPES = [
{ value: '', label: 'Все' },
{ value: 'Supply', label: 'Приёмки' },
{ value: 'SupplierReturn', label: 'Возвраты поставщикам' },
{ value: 'RetailSale', label: 'Чеки розничные' },
{ value: 'Demand', label: 'Оптовые отгрузки' },
{ value: 'Product', label: 'Товары' },
{ value: 'Counterparty', label: 'Контрагенты' },
]
/** Журнал мутаций tenant'а — кто, что и когда менял. Read-only.
* Запись делает OrgAuditInterceptor автоматически на каждом SaveChanges. */
interface EmployeeOption { userId: string | null; fullName: string }
export function OrgAuditLogPage() {
const [page, setPage] = useState(1)
const [entityType, setEntityType] = useState('')
const [action, setAction] = useState('')
const [userId, setUserId] = useState('')
const [from, setFrom] = useState('') // 'yyyy-MM-dd' из <input type="date">
const [to, setTo] = useState('')
const [search, setSearch] = useState('')
// Список сотрудников для фильтра «Кто». Та же permission что и audit-log,
// подгружается раз на сессию (staleTime). Кешируется в TanStack Query.
const employees = useQuery({
queryKey: ['/api/employees', 'audit-log-filter'],
queryFn: async () => (await api.get<{ items: EmployeeOption[] }>('/api/employees?pageSize=200')).data,
staleTime: 5 * 60 * 1000,
})
const params = new URLSearchParams({ page: String(page), pageSize: '50' })
if (entityType) params.set('entityType', entityType)
if (action) params.set('action', action)
if (userId) params.set('userId', userId)
// <input type="date"> отдаёт 'yyyy-MM-dd'. API ждёт DateTime → добавляем
// границы дня. AsUtc() в контроллере конвертит в UTC.
if (from) params.set('from', `${from}T00:00:00`)
if (to) params.set('to', `${to}T23:59:59`)
const rep = useQuery({
queryKey: ['audit-log', page, entityType, action, userId, from, to],
queryFn: async () => (await api.get<PagedResult<AuditRow>>(`/api/admin/audit-log?${params}`)).data,
placeholderData: (prev) => prev,
})
const resetFilters = () => {
setEntityType(''); setAction(''); setUserId(''); setFrom(''); setTo(''); setSearch(''); setPage(1)
}
const hasFilters = !!(entityType || action || userId || from || to || search)
const filtered = (rep.data?.items ?? []).filter((r) => {
if (!search) return true
const s = search.toLowerCase()
return (r.userName ?? '').toLowerCase().includes(s)
|| r.entityType.toLowerCase().includes(s)
|| r.changesJson.toLowerCase().includes(s)
})
return (
<ListPageShell
title="Журнал изменений"
description={rep.data ? `${rep.data.total.toLocaleString('ru')} событий` : 'Все мутации документов и справочников.'}
helpTopic="audit-log"
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="По имени, типу, тексту…" />
</>
}
footer={rep.data && rep.data.total > 0 && (
<Pagination page={page} pageSize={rep.data.pageSize} total={rep.data.total} onPageChange={setPage} />
)}
>
<div className="flex flex-wrap gap-3 mb-3 items-end">
<Field label="Тип сущности">
<Select value={entityType} onChange={(e) => { setEntityType(e.target.value); setPage(1) }}>
{ENTITY_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</Select>
</Field>
<Field label="Действие">
<Select value={action} onChange={(e) => { setAction(e.target.value); setPage(1) }}>
{ACTIONS.map((a) => <option key={a.value} value={a.value}>{a.label}</option>)}
</Select>
</Field>
<Field label="Кто">
<Select value={userId} onChange={(e) => { setUserId(e.target.value); setPage(1) }}>
<option value="">Все</option>
{(employees.data?.items ?? [])
.filter((u) => u.userId)
.map((u) => <option key={u.userId!} value={u.userId!}>{u.fullName}</option>)}
</Select>
</Field>
<Field label="Дата с">
<input
type="date"
value={from}
onChange={(e) => { setFrom(e.target.value); setPage(1) }}
className="h-9 px-2 border border-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 rounded text-sm"
/>
</Field>
<Field label="по">
<input
type="date"
value={to}
onChange={(e) => { setTo(e.target.value); setPage(1) }}
className="h-9 px-2 border border-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 rounded text-sm"
/>
</Field>
{hasFilters && (
<button
type="button"
onClick={resetFilters}
className="h-9 px-3 text-xs text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100 underline"
>
Сбросить фильтры
</button>
)}
</div>
<DataTable
rows={filtered}
isLoading={rep.isLoading}
rowKey={(r) => r.id}
columns={[
{ header: 'Когда', width: '170px', cell: (r) => new Date(r.createdAt).toLocaleString('ru') },
{ header: 'Кто', width: '180px', cell: (r) => r.userName ?? <span className="text-slate-500 dark:text-slate-400">система</span> },
{ header: 'Тип', width: '140px', cell: (r) => r.entityType },
{ header: 'Действие', width: '110px', cell: (r) => (
<span className={`text-xs px-2 py-0.5 rounded ${
r.action === 'create' ? 'bg-green-50 text-green-700' :
r.action === 'delete' ? 'bg-red-50 text-red-700' :
'bg-slate-100 text-slate-700'
}`}>{r.action}</span>
)},
{ header: 'EntityId', width: '110px', cell: (r) => (
r.entityId ? <span className="font-mono text-xs text-slate-500 dark:text-slate-400">{r.entityId.slice(0, 8)}</span> : '—'
)},
{ header: 'Изменения', cell: (r) => (
<details className="text-xs">
<summary className="cursor-pointer text-slate-500 dark:text-slate-400 hover:text-slate-700">показать diff</summary>
<pre className="mt-1 p-2 bg-slate-50 dark:bg-slate-800 rounded text-[10px] max-w-2xl overflow-x-auto">{
(() => {
try { return JSON.stringify(JSON.parse(r.changesJson), null, 2) }
catch { return r.changesJson }
})()
}</pre>
</details>
)},
]}
empty="Событий пока нет."
/>
</ListPageShell>
)
}