feat(web): панель «Проверка данных» + бейдж на главной (п.2,3,4)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
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 Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

Страница /catalog/data-quality: счётчики по проверкам, детальные списки, массовое
исправление (задать группу/цену, сгенерировать штрихкоды), «это разные товары» для
дублей, пересчёт, тумблеры отключения проверок. Ненавязчивый бейдж «N товаров требуют
внимания» на дашборде (исчезает при 0). Пункт меню «Проверка данных» в каталоге.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 08:15:28 +05:00
parent 261a720b52
commit bcd44af3a7
8 changed files with 228 additions and 0 deletions

View file

@ -74,6 +74,7 @@ const DemandsPage = lazy(() => import('@/pages/DemandsPage').then(m => ({ defaul
const DemandEditPage = lazy(() => import('@/pages/DemandEditPage').then(m => ({ default: m.DemandEditPage })))
const ProductGroupsPage = lazy(() => import('@/pages/ProductGroupsPage').then(m => ({ default: m.ProductGroupsPage })))
const UnitsOfMeasurePage = lazy(() => import('@/pages/UnitsOfMeasurePage').then(m => ({ default: m.UnitsOfMeasurePage })))
const DataQualityPage = lazy(() => import('@/pages/DataQualityPage').then(m => ({ default: m.DataQualityPage })))
const PriceTypesPage = lazy(() => import('@/pages/PriceTypesPage').then(m => ({ default: m.PriceTypesPage })))
const StoresPage = lazy(() => import('@/pages/StoresPage').then(m => ({ default: m.StoresPage })))
const RetailPointsPage = lazy(() => import('@/pages/RetailPointsPage').then(m => ({ default: m.RetailPointsPage })))
@ -170,6 +171,7 @@ export default function App() {
<Route path="/catalog/products/:id" element={<ProductEditPage />} />
<Route path="/catalog/product-groups" element={lz(ProductGroupsPage)} />
<Route path="/catalog/units" element={lz(UnitsOfMeasurePage)} />
<Route path="/catalog/data-quality" element={lz(DataQualityPage)} />
<Route path="/catalog/price-types" element={<AdvancedModeGuard>{lz(PriceTypesPage)}</AdvancedModeGuard>} />
<Route path="/catalog/counterparties" element={<RoleGuard roles={['Admin']}><CounterpartiesPage /></RoleGuard>} />
<Route path="/catalog/stores" element={<RoleGuard roles={['Admin']}>{lz(StoresPage)}</RoleGuard>} />

View file

@ -87,6 +87,7 @@ function buildNav(roles: string[], advancedMode: boolean): NavSection[] {
catalog.push(
{ to: '/catalog/product-groups', icon: FolderTree, label: 'nav.productGroups' },
{ to: '/catalog/units', icon: Ruler, label: 'nav.units' },
{ to: '/catalog/data-quality', icon: ShieldCheck, label: 'nav.dataQuality' },
)
// В простом режиме у магазина одна цена продажи — страница управления
// типами цен не нужна. SuperAdmin и расширенный режим — видят.

View file

@ -0,0 +1,22 @@
import { Link } from 'react-router-dom'
import { ShieldAlert } from 'lucide-react'
import { useDataQuality } from '@/lib/useDataQuality'
/**
* Ненавязчивый баннер «Проверка данных» на главной/в каталоге: показывает число
* товаров с проблемами (без группы/цены/штрихкода, дубли), пока они есть. Клик —
* на панель исправления. Исчезает, когда проблем нет.
*/
export function DataQualityBadge() {
const { data } = useDataQuality()
const total = data?.total ?? 0
if (total === 0) return null
return (
<Link to="/catalog/data-quality"
className="flex items-center gap-2 rounded-lg border border-amber-300 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/30 px-4 py-2 text-sm text-amber-900 dark:text-amber-200 hover:bg-amber-100 dark:hover:bg-amber-950/50">
<ShieldAlert className="w-4 h-4 text-amber-600 shrink-0" />
<span><b>{total}</b> {total === 1 ? 'товар требует' : 'товаров требуют'} внимания в справочнике — проверьте данные</span>
<span className="ml-auto text-xs underline">Открыть</span>
</Link>
)
}

View file

@ -0,0 +1,40 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from './api'
export interface DqCheck { checkType: string; label: string; count: number; enabled: boolean; link: string | null }
export interface DqSummary { checks: DqCheck[]; total: number; lastCheckedAt: string | null }
export interface DqProduct {
id: string; name: string; article: string | null; groupName: string | null
unitName: string | null; hasPrice: boolean; barcodeCount: number; barcode: string | null; salePrice: number | null
}
/** Сводка «Проверки данных» (из сохранённых результатов job-а). Для бейджа и панели. */
export function useDataQuality() {
return useQuery<DqSummary>({
queryKey: ['/api/data-quality'],
queryFn: async () => (await api.get<DqSummary>('/api/data-quality')).data,
refetchInterval: 120_000,
staleTime: 60_000,
})
}
export function useDqProducts(checkType: string | null) {
return useQuery<DqProduct[]>({
queryKey: ['/api/data-quality/products', checkType],
queryFn: async () => (await api.get<DqProduct[]>(`/api/data-quality/${checkType}/products?take=200`)).data,
enabled: !!checkType,
})
}
export function useDqMutations() {
const qc = useQueryClient()
const inval = () => { qc.invalidateQueries({ queryKey: ['/api/data-quality'] }); qc.invalidateQueries({ queryKey: ['/api/data-quality/products'] }) }
return {
recompute: useMutation({ mutationFn: async () => (await api.post('/api/data-quality/recompute')).data, onSuccess: inval }),
setGroup: useMutation({ mutationFn: async (v: { productIds: string[]; groupId: string }) => (await api.post('/api/data-quality/bulk/set-group', v)).data, onSuccess: inval }),
setPrice: useMutation({ mutationFn: async (v: { productIds: string[]; amount: number }) => (await api.post('/api/data-quality/bulk/set-price', { ...v, priceTypeId: null })).data, onSuccess: inval }),
genBarcodes: useMutation({ mutationFn: async (v: { productIds: string[] }) => (await api.post('/api/data-quality/bulk/generate-barcodes', v)).data, onSuccess: inval }),
dismiss: useMutation({ mutationFn: async (v: { checkType: string; productId?: string; barcode?: string }) => (await api.post('/api/data-quality/dismiss', v)).data, onSuccess: inval }),
saveSettings: useMutation({ mutationFn: async (disabledChecks: string[]) => (await api.put('/api/data-quality/settings', { disabledChecks })).data, onSuccess: inval }),
}
}

View file

@ -57,6 +57,7 @@
"products": "Products",
"productGroups": "Groups",
"units": "Units",
"dataQuality": "Data quality",
"priceTypes": "Price types",
"counterparties": "Counterparties",
"stock": "Stock",

View file

@ -57,6 +57,7 @@
"products": "Товары",
"productGroups": "Группы",
"units": "Ед. измерения",
"dataQuality": "Проверка данных",
"priceTypes": "Типы цен",
"counterparties": "Контрагенты",
"stock": "Остатки",

View file

@ -5,6 +5,7 @@ import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Ba
import { PageHeader } from '@/components/PageHeader'
import { OverdueNegativeStockBlock } from '@/components/OverdueNegativeStockBlock'
import { ProfitHomeBlock } from '@/components/ProfitHomeBlock'
import { DataQualityBadge } from '@/components/DataQualityBadge'
import { Skeleton } from '@/components/Skeleton'
import { api } from '@/lib/api'
import { toast } from '@/lib/toast'
@ -164,6 +165,9 @@ export function DashboardPage() {
{/* Эскалация: товары, висящие в минусе дольше порога (item 11). */}
<OverdueNegativeStockBlock />
{/* Ненавязчивая проверка данных (фоновая). */}
<DataQualityBadge />
{/* Прибыль за сегодня/месяц — на главной во всех режимах (п.8 паритета). */}
<ProfitHomeBlock />

View file

@ -0,0 +1,157 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { RefreshCw, CheckCircle2, AlertTriangle, ExternalLink } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/Button'
import { Select, TextInput } from '@/components/Field'
import { useDataQuality, useDqProducts, useDqMutations } from '@/lib/useDataQuality'
import { useProductGroups } from '@/lib/useLookups'
const ALL_CHECKS = ['no-group', 'no-sale-price', 'no-barcode', 'dup-barcode', 'dup-name-unit']
export function DataQualityPage() {
const { data } = useDataQuality()
const m = useDqMutations()
const groups = useProductGroups()
const [active, setActive] = useState<string | null>(null)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [groupId, setGroupId] = useState('')
const [price, setPrice] = useState('')
const [showSettings, setShowSettings] = useState(false)
const products = useDqProducts(active && active !== 'negative-stock' ? active : null)
const rows = products.data ?? []
const isDup = active === 'dup-barcode' || active === 'dup-name-unit'
const toggle = (id: string) => setSelected((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n })
const selectAll = () => setSelected(new Set(rows.map((r) => r.id)))
const ids = [...selected]
const lastText = data?.lastCheckedAt
? `проверено ${new Date(data.lastCheckedAt).toLocaleString('ru', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}`
: 'ещё не проверялось'
return (
<div className="h-full overflow-auto">
<div className="p-4 sm:p-6 max-w-6xl">
<PageHeader title="Проверка данных"
description={`Фоновая проверка качества справочника — ${lastText}.`}
actions={<>
<Button variant="secondary" size="sm" onClick={() => setShowSettings((v) => !v)}>Проверки</Button>
<Button size="sm" disabled={m.recompute.isPending} onClick={() => m.recompute.mutate()}>
<RefreshCw className={`w-4 h-4 ${m.recompute.isPending ? 'animate-spin' : ''}`} /> Пересчитать
</Button>
</>}
/>
{showSettings && (
<div className="mb-4 rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-4">
<div className="text-sm font-medium mb-2">Включённые проверки</div>
<div className="flex flex-wrap gap-3">
{ALL_CHECKS.map((c) => {
const chk = data?.checks.find((x) => x.checkType === c)
const enabled = chk?.enabled ?? true
return (
<label key={c} className="inline-flex items-center gap-2 text-sm">
<input type="checkbox" checked={enabled} onChange={() => {
const disabled = ALL_CHECKS.filter((x) => {
const e = data?.checks.find((y) => y.checkType === x)?.enabled ?? true
return x === c ? enabled : !e
})
m.saveSettings.mutate(disabled)
}} />
{chk?.label ?? c}
</label>
)
})}
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Список проверок со счётчиками */}
<div className="space-y-2">
{(data?.checks ?? []).map((c) => {
const ok = c.count === 0
return (
<button key={c.checkType} type="button"
onClick={() => { if (c.checkType !== 'negative-stock') { setActive(c.checkType); setSelected(new Set()) } }}
disabled={!c.enabled}
className={`w-full text-left rounded-xl border p-3 flex items-center justify-between ${active === c.checkType ? 'border-amber-400 bg-amber-50/60 dark:bg-amber-950/20' : 'border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900'} ${!c.enabled ? 'opacity-50' : 'hover:border-amber-300'}`}>
<span className="flex items-center gap-2 text-sm">
{ok ? <CheckCircle2 className="w-4 h-4 text-emerald-500" /> : <AlertTriangle className="w-4 h-4 text-amber-500" />}
{c.label}{!c.enabled && <span className="text-xs text-slate-400">(выкл.)</span>}
</span>
{c.checkType === 'negative-stock' && c.link ? (
<Link to={c.link} className="text-xs text-blue-600 inline-flex items-center gap-1">{c.count} <ExternalLink className="w-3 h-3" /></Link>
) : (
<span className={`text-sm font-semibold ${ok ? 'text-emerald-600' : 'text-amber-700 dark:text-amber-400'}`}>{c.count}</span>
)}
</button>
)
})}
</div>
{/* Детальный список + массовое исправление */}
<div className="lg:col-span-2">
{!active ? (
<div className="rounded-xl border border-dashed border-slate-300 dark:border-slate-700 p-8 text-center text-sm text-slate-500">
Выберите проверку слева, чтобы увидеть товары и исправить их пачкой.
</div>
) : (
<div className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
{/* Панель массового действия */}
{!isDup && (
<div className="p-3 border-b border-slate-100 dark:border-slate-800 flex flex-wrap items-center gap-2">
<button type="button" onClick={selectAll} className="text-xs text-blue-600 hover:underline">Выбрать все ({rows.length})</button>
<span className="text-xs text-slate-400">выбрано {ids.length}</span>
<div className="ml-auto flex items-center gap-2">
{active === 'no-group' && <>
<Select value={groupId} onChange={(e) => setGroupId(e.target.value)} className="w-44">
<option value="">— группа —</option>
{(groups.data ?? []).map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
</Select>
<Button size="sm" disabled={!groupId || ids.length === 0}
onClick={() => m.setGroup.mutate({ productIds: ids, groupId }, { onSuccess: () => setSelected(new Set()) })}>Задать группу</Button>
</>}
{active === 'no-sale-price' && <>
<TextInput type="number" min={1} placeholder="Цена ₸" value={price} onChange={(e) => setPrice(e.target.value)} className="w-28" />
<Button size="sm" disabled={!price || ids.length === 0}
onClick={() => m.setPrice.mutate({ productIds: ids, amount: Number(price) }, { onSuccess: () => setSelected(new Set()) })}>Задать цену</Button>
</>}
{active === 'no-barcode' && (
<Button size="sm" disabled={ids.length === 0}
onClick={() => m.genBarcodes.mutate({ productIds: ids }, { onSuccess: () => setSelected(new Set()) })}>Сгенерировать штрихкоды</Button>
)}
</div>
</div>
)}
<div className="max-h-[60vh] overflow-auto divide-y divide-slate-100 dark:divide-slate-800">
{rows.length === 0 ? (
<div className="p-6 text-center text-sm text-emerald-600">Проблем не найдено 🎉</div>
) : rows.map((r) => (
<div key={r.id} className="flex items-center gap-3 px-3 py-2 text-sm">
{!isDup && <input type="checkbox" checked={selected.has(r.id)} onChange={() => toggle(r.id)} />}
<Link to={`/catalog/products/${r.id}`} className="flex-1 min-w-0 truncate hover:underline">
{r.name} {r.article && <span className="text-xs text-slate-400">{r.article}</span>}
</Link>
<span className="text-xs text-slate-400 hidden sm:inline">
{r.groupName} · {r.hasPrice ? `${r.salePrice} ₸` : 'без цены'} · {r.barcodeCount > 0 ? r.barcode : 'без ШК'}
</span>
{isDup && (
<button type="button" className="text-xs text-slate-500 hover:text-slate-800 whitespace-nowrap"
onClick={() => m.dismiss.mutate({ checkType: active!, productId: r.id, barcode: r.barcode ?? undefined })}>
Это разные товары
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}