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
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:
parent
261a720b52
commit
bcd44af3a7
|
|
@ -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>} />
|
||||
|
|
|
|||
|
|
@ -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 и расширенный режим — видят.
|
||||
|
|
|
|||
22
src/food-market.web/src/components/DataQualityBadge.tsx
Normal file
22
src/food-market.web/src/components/DataQualityBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
src/food-market.web/src/lib/useDataQuality.ts
Normal file
40
src/food-market.web/src/lib/useDataQuality.ts
Normal 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 }),
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@
|
|||
"products": "Products",
|
||||
"productGroups": "Groups",
|
||||
"units": "Units",
|
||||
"dataQuality": "Data quality",
|
||||
"priceTypes": "Price types",
|
||||
"counterparties": "Counterparties",
|
||||
"stock": "Stock",
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@
|
|||
"products": "Товары",
|
||||
"productGroups": "Группы",
|
||||
"units": "Ед. измерения",
|
||||
"dataQuality": "Проверка данных",
|
||||
"priceTypes": "Типы цен",
|
||||
"counterparties": "Контрагенты",
|
||||
"stock": "Остатки",
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
157
src/food-market.web/src/pages/DataQualityPage.tsx
Normal file
157
src/food-market.web/src/pages/DataQualityPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue