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
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>
183 lines
7.7 KiB
TypeScript
183 lines
7.7 KiB
TypeScript
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>
|
||
)
|
||
}
|