diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 8838226..edc337d 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -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() { } /> + {lz(PriceTypesPage)}} /> } /> {lz(StoresPage)}} /> diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index d9c7e2b..06ac0d7 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -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 и расширенный режим — видят. diff --git a/src/food-market.web/src/components/DataQualityBadge.tsx b/src/food-market.web/src/components/DataQualityBadge.tsx new file mode 100644 index 0000000..036ffc9 --- /dev/null +++ b/src/food-market.web/src/components/DataQualityBadge.tsx @@ -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 ( + + + {total} {total === 1 ? 'товар требует' : 'товаров требуют'} внимания в справочнике — проверьте данные + Открыть + + ) +} diff --git a/src/food-market.web/src/lib/useDataQuality.ts b/src/food-market.web/src/lib/useDataQuality.ts new file mode 100644 index 0000000..191ee9c --- /dev/null +++ b/src/food-market.web/src/lib/useDataQuality.ts @@ -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({ + queryKey: ['/api/data-quality'], + queryFn: async () => (await api.get('/api/data-quality')).data, + refetchInterval: 120_000, + staleTime: 60_000, + }) +} + +export function useDqProducts(checkType: string | null) { + return useQuery({ + queryKey: ['/api/data-quality/products', checkType], + queryFn: async () => (await api.get(`/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 }), + } +} diff --git a/src/food-market.web/src/locales/en.json b/src/food-market.web/src/locales/en.json index c37c07b..94d30dd 100644 --- a/src/food-market.web/src/locales/en.json +++ b/src/food-market.web/src/locales/en.json @@ -57,6 +57,7 @@ "products": "Products", "productGroups": "Groups", "units": "Units", + "dataQuality": "Data quality", "priceTypes": "Price types", "counterparties": "Counterparties", "stock": "Stock", diff --git a/src/food-market.web/src/locales/ru.json b/src/food-market.web/src/locales/ru.json index e14a157..fd76873 100644 --- a/src/food-market.web/src/locales/ru.json +++ b/src/food-market.web/src/locales/ru.json @@ -57,6 +57,7 @@ "products": "Товары", "productGroups": "Группы", "units": "Ед. измерения", + "dataQuality": "Проверка данных", "priceTypes": "Типы цен", "counterparties": "Контрагенты", "stock": "Остатки", diff --git a/src/food-market.web/src/pages/DashboardPage.tsx b/src/food-market.web/src/pages/DashboardPage.tsx index d98289c..da2a914 100644 --- a/src/food-market.web/src/pages/DashboardPage.tsx +++ b/src/food-market.web/src/pages/DashboardPage.tsx @@ -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). */} + {/* Ненавязчивая проверка данных (фоновая). */} + + {/* Прибыль за сегодня/месяц — на главной во всех режимах (п.8 паритета). */} diff --git a/src/food-market.web/src/pages/DataQualityPage.tsx b/src/food-market.web/src/pages/DataQualityPage.tsx new file mode 100644 index 0000000..005fe35 --- /dev/null +++ b/src/food-market.web/src/pages/DataQualityPage.tsx @@ -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(null) + const [selected, setSelected] = useState>(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 ( +
+
+ + + + } + /> + + {showSettings && ( +
+
Включённые проверки
+
+ {ALL_CHECKS.map((c) => { + const chk = data?.checks.find((x) => x.checkType === c) + const enabled = chk?.enabled ?? true + return ( + + ) + })} +
+
+ )} + +
+ {/* Список проверок со счётчиками */} +
+ {(data?.checks ?? []).map((c) => { + const ok = c.count === 0 + return ( + + ) + })} +
+ + {/* Детальный список + массовое исправление */} +
+ {!active ? ( +
+ Выберите проверку слева, чтобы увидеть товары и исправить их пачкой. +
+ ) : ( +
+ {/* Панель массового действия */} + {!isDup && ( +
+ + выбрано {ids.length} +
+ {active === 'no-group' && <> + + + } + {active === 'no-sale-price' && <> + setPrice(e.target.value)} className="w-28" /> + + } + {active === 'no-barcode' && ( + + )} +
+
+ )} +
+ {rows.length === 0 ? ( +
Проблем не найдено 🎉
+ ) : rows.map((r) => ( +
+ {!isDup && toggle(r.id)} />} + + {r.name} {r.article && {r.article}} + + + {r.groupName} · {r.hasPrice ? `${r.salePrice} ₸` : 'без цены'} · {r.barcodeCount > 0 ? r.barcode : 'без ШК'} + + {isDup && ( + + )} +
+ ))} +
+
+ )} +
+
+
+
+ ) +}