mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-05 03:12:34 -06:00
added front - react+ts
ocr improved
This commit is contained in:
27
rmser-view/src/App.tsx
Normal file
27
rmser-view/src/App.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Providers } from './components/layout/Providers';
|
||||
import { AppLayout } from './components/layout/AppLayout';
|
||||
import { Dashboard } from './pages/Dashboard'; // Импортируем созданную страницу
|
||||
import { OcrLearning } from './pages/OcrLearning';
|
||||
|
||||
// Заглушки для остальных страниц пока оставим
|
||||
const InvoicesPage = () => <h2>Список накладных</h2>;
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Providers>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppLayout />}>
|
||||
<Route index element={<Dashboard />} /> {/* Используем компонент */}
|
||||
<Route path="ocr" element={<OcrLearning />} />
|
||||
<Route path="invoices" element={<InvoicesPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
57
rmser-view/src/components/layout/AppLayout.tsx
Normal file
57
rmser-view/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { Layout, Menu, theme } from 'antd';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { BarChartOutlined, ScanOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Header, Content, Footer } = Layout;
|
||||
|
||||
export const AppLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Получаем токены темы (чтобы подстроить AntD под Telegram можно позже настроить ConfigProvider)
|
||||
const {
|
||||
token: { colorBgContainer, borderRadiusLG },
|
||||
} = theme.useToken();
|
||||
|
||||
// Определяем активный пункт меню
|
||||
const selectedKey = location.pathname === '/' ? 'dashboard'
|
||||
: location.pathname.startsWith('/ocr') ? 'ocr'
|
||||
: location.pathname.startsWith('/invoices') ? 'invoices'
|
||||
: 'dashboard';
|
||||
|
||||
const menuItems = [
|
||||
{ key: 'dashboard', icon: <BarChartOutlined />, label: 'Дашборд', onClick: () => navigate('/') },
|
||||
{ key: 'ocr', icon: <ScanOutlined />, label: 'Обучение', onClick: () => navigate('/ocr') },
|
||||
{ key: 'invoices', icon: <FileTextOutlined />, label: 'Накладные', onClick: () => navigate('/invoices') },
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Header style={{ display: 'flex', alignItems: 'center', padding: 0 }}>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="horizontal"
|
||||
selectedKeys={[selectedKey]}
|
||||
items={menuItems}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
</Header>
|
||||
<Content style={{ padding: '16px' }}>
|
||||
<div
|
||||
style={{
|
||||
background: colorBgContainer,
|
||||
minHeight: 280,
|
||||
padding: 24,
|
||||
borderRadius: borderRadiusLG,
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Content>
|
||||
<Footer style={{ textAlign: 'center', padding: '12px 0' }}>
|
||||
RMSer ©{new Date().getFullYear()}
|
||||
</Footer>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
43
rmser-view/src/components/layout/Providers.tsx
Normal file
43
rmser-view/src/components/layout/Providers.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import WebApp from '@twa-dev/sdk';
|
||||
|
||||
// Настройка клиента React Query
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false, // Не перезапрашивать при переключении вкладок
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Providers: React.FC<ProvidersProps> = ({ children }) => {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Инициализация Telegram Mini App
|
||||
WebApp.ready();
|
||||
WebApp.expand(); // Разворачиваем на весь экран
|
||||
|
||||
// Подстраиваем цвет хедера под тему Telegram
|
||||
WebApp.setHeaderColor('secondary_bg_color');
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsReady(true);
|
||||
}, []);
|
||||
|
||||
if (!isReady) {
|
||||
return <div style={{ padding: 20, textAlign: 'center' }}>Loading Telegram SDK...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
143
rmser-view/src/components/ocr/AddMatchForm.tsx
Normal file
143
rmser-view/src/components/ocr/AddMatchForm.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React, { useState, useMemo } from 'react'; // Убрали useEffect
|
||||
import { Card, Button, Flex, AutoComplete, InputNumber, Typography, Select } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { CatalogSelect } from './CatalogSelect';
|
||||
import type { CatalogItem, UnmatchedItem } from '../../services/types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
catalog: CatalogItem[];
|
||||
unmatched?: UnmatchedItem[];
|
||||
onSave: (rawName: string, productId: string, quantity: number, containerId?: string) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave, isLoading }) => {
|
||||
const [rawName, setRawName] = useState('');
|
||||
const [selectedProduct, setSelectedProduct] = useState<string | undefined>(undefined);
|
||||
const [quantity, setQuantity] = useState<number | null>(1);
|
||||
const [selectedContainer, setSelectedContainer] = useState<string | null>(null);
|
||||
|
||||
const unmatchedOptions = useMemo(() => {
|
||||
return unmatched.map(item => ({
|
||||
value: item.raw_name,
|
||||
label: item.count ? `${item.raw_name} (${item.count} шт)` : item.raw_name
|
||||
}));
|
||||
}, [unmatched]);
|
||||
|
||||
const selectedCatalogItem = useMemo(() => {
|
||||
if (!selectedProduct) return null;
|
||||
return catalog.find(item => item.id === selectedProduct || item.ID === selectedProduct);
|
||||
}, [selectedProduct, catalog]);
|
||||
|
||||
// Хендлер смены товара: сразу сбрасываем фасовку
|
||||
const handleProductChange = (val: string) => {
|
||||
setSelectedProduct(val);
|
||||
setSelectedContainer(null);
|
||||
};
|
||||
|
||||
// Мемоизируем список контейнеров, чтобы он был стабильной зависимостью
|
||||
const containers = useMemo(() => {
|
||||
return selectedCatalogItem?.containers || selectedCatalogItem?.Containers || [];
|
||||
}, [selectedCatalogItem]);
|
||||
|
||||
const baseUom = selectedCatalogItem?.measure_unit || selectedCatalogItem?.MeasureUnit || 'ед.';
|
||||
|
||||
const currentUomName = useMemo(() => {
|
||||
if (selectedContainer) {
|
||||
const cont = containers.find(c => c.id === selectedContainer);
|
||||
return cont ? cont.name : baseUom;
|
||||
}
|
||||
return baseUom;
|
||||
}, [selectedContainer, containers, baseUom]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (rawName.trim() && selectedProduct && quantity && quantity > 0) {
|
||||
onSave(rawName, selectedProduct, quantity, selectedContainer || undefined);
|
||||
|
||||
setRawName('');
|
||||
setSelectedProduct(undefined);
|
||||
setQuantity(1);
|
||||
setSelectedContainer(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isButtonDisabled = !rawName.trim() || !selectedProduct || !quantity || quantity <= 0 || isLoading;
|
||||
|
||||
return (
|
||||
<Card title="Добавить новую связь" size="small" style={{ marginBottom: 16 }}>
|
||||
<Flex vertical gap="middle">
|
||||
<div>
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>Текст из чека:</div>
|
||||
<AutoComplete
|
||||
placeholder="Например: Масло слив. коробка"
|
||||
options={unmatchedOptions}
|
||||
value={rawName}
|
||||
onChange={setRawName}
|
||||
filterOption={(inputValue, option) =>
|
||||
!inputValue || (option?.value as string).toLowerCase().includes(inputValue.toLowerCase())
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>Товар в iiko:</div>
|
||||
<CatalogSelect
|
||||
catalog={catalog}
|
||||
value={selectedProduct}
|
||||
onChange={handleProductChange} // Используем новый хендлер
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{containers.length > 0 && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>Единица измерения / Фасовка:</div>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={selectedContainer}
|
||||
onChange={setSelectedContainer}
|
||||
options={[
|
||||
{ value: null, label: `Базовая единица (${baseUom})` },
|
||||
...containers.map(c => ({
|
||||
value: c.id,
|
||||
label: `${c.name} (=${c.count} ${baseUom})`
|
||||
}))
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>
|
||||
Количество (в выбранных единицах):
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<InputNumber
|
||||
min={0.001}
|
||||
step={selectedContainer ? 1 : 0.1}
|
||||
value={quantity}
|
||||
onChange={setQuantity}
|
||||
style={{ flex: 1 }}
|
||||
placeholder="1"
|
||||
/>
|
||||
<Text strong>{currentUomName}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleSubmit}
|
||||
loading={isLoading}
|
||||
disabled={isButtonDisabled}
|
||||
block
|
||||
>
|
||||
Сохранить связь
|
||||
</Button>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
56
rmser-view/src/components/ocr/CatalogSelect.tsx
Normal file
56
rmser-view/src/components/ocr/CatalogSelect.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import type { CatalogItem } from '../../services/types';
|
||||
|
||||
interface Props {
|
||||
catalog: CatalogItem[];
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const CatalogSelect: React.FC<Props> = ({ catalog, value, onChange, disabled }) => {
|
||||
const options = useMemo(() => {
|
||||
return catalog.map((item) => {
|
||||
const name = item.name || item.Name || 'Неизвестный товар';
|
||||
// Гарантируем строку. Если ID нет, будет пустая строка, которую мы отфильтруем.
|
||||
const id = item.id || item.ID || '';
|
||||
const code = item.code || item.Code || '';
|
||||
// const uom = item.measure_unit || item.MeasureUnit || ''; // Можно добавить в label
|
||||
|
||||
return {
|
||||
label: code ? `${name} [${code}]` : name,
|
||||
value: id,
|
||||
code: code,
|
||||
name: name,
|
||||
};
|
||||
})
|
||||
// TypeScript Predicate: явно говорим компилятору, что после фильтра value точно string (и не пустая)
|
||||
.filter((opt): opt is { label: string; value: string; code: string; name: string } => !!opt.value);
|
||||
}, [catalog]);
|
||||
|
||||
const filterOption = (input: string, option?: { label: string; value: string; code: string; name: string }) => {
|
||||
if (!option) return false;
|
||||
|
||||
const search = input.toLowerCase();
|
||||
const name = (option.name || '').toLowerCase();
|
||||
const code = (option.code || '').toLowerCase();
|
||||
|
||||
return name.includes(search) || code.includes(search);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Выберите товар из iiko"
|
||||
optionFilterProp="children"
|
||||
filterOption={filterOption}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
style={{ width: '100%' }}
|
||||
listHeight={256}
|
||||
/>
|
||||
);
|
||||
};
|
||||
79
rmser-view/src/components/ocr/MatchList.tsx
Normal file
79
rmser-view/src/components/ocr/MatchList.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { List, Typography, Tag, Input, Empty } from 'antd';
|
||||
import { ArrowRightOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import type { ProductMatch } from '../../services/types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
matches: ProductMatch[];
|
||||
}
|
||||
|
||||
export const MatchList: React.FC<Props> = ({ matches }) => {
|
||||
const [searchText, setSearchText] = React.useState('');
|
||||
|
||||
const filteredData = matches.filter(item => {
|
||||
const raw = (item.raw_name || item.RawName || '').toLowerCase();
|
||||
const prod = item.product || item.Product;
|
||||
const prodName = (prod?.name || prod?.Name || '').toLowerCase();
|
||||
const search = searchText.toLowerCase();
|
||||
return raw.includes(search) || prodName.includes(search);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Поиск по связям..."
|
||||
prefix={<SearchOutlined style={{ color: '#ccc' }} />}
|
||||
style={{ marginBottom: 12 }}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
|
||||
<List
|
||||
itemLayout="vertical"
|
||||
dataSource={filteredData}
|
||||
locale={{ emptyText: <Empty description="Нет данных" /> }}
|
||||
pagination={{ pageSize: 10, size: "small", simple: true }}
|
||||
renderItem={(item) => {
|
||||
// Унификация полей
|
||||
const rawName = item.raw_name || item.RawName || 'Без названия';
|
||||
const product = item.product || item.Product;
|
||||
const productName = product?.name || product?.Name || 'Товар не найден';
|
||||
const qty = item.quantity || item.Quantity || 1;
|
||||
|
||||
// Логика отображения Единицы или Фасовки
|
||||
const container = item.container || item.Container;
|
||||
let displayUnit = '';
|
||||
|
||||
if (container) {
|
||||
// Если есть фасовка: "Пачка 180г"
|
||||
displayUnit = container.name;
|
||||
} else {
|
||||
// Иначе базовая ед.: "кг"
|
||||
displayUnit = product?.measure_unit || product?.MeasureUnit || 'ед.';
|
||||
}
|
||||
|
||||
return (
|
||||
<List.Item style={{ background: '#fff', padding: 12, marginBottom: 8, borderRadius: 8 }}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<Tag color="geekblue">Чек</Tag>
|
||||
<Text strong>{rawName}</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#888' }}>
|
||||
<ArrowRightOutlined />
|
||||
<Text>
|
||||
{productName}
|
||||
<Text strong style={{ color: '#555', marginLeft: 6 }}>
|
||||
x {qty} {displayUnit}
|
||||
</Text>
|
||||
</Text>
|
||||
</div>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Card, Tag, Typography, Button } from 'antd';
|
||||
import { WarningOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import type { Recommendation } from '../../services/types';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
interface Props {
|
||||
item: Recommendation;
|
||||
}
|
||||
|
||||
export const RecommendationCard: React.FC<Props> = ({ item }) => {
|
||||
// Выбираем цвет тега в зависимости от типа проблемы
|
||||
const getTagColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'UNUSED_IN_RECIPES': return 'volcano';
|
||||
case 'NO_INCOMING': return 'gold';
|
||||
default: return 'blue';
|
||||
}
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
return type === 'UNUSED_IN_RECIPES' ? <WarningOutlined /> : <InfoCircleOutlined />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{getIcon(item.Type)}
|
||||
<Text strong ellipsis>{item.ProductName}</Text>
|
||||
</div>
|
||||
}
|
||||
extra={<Tag color={getTagColor(item.Type)}>{item.Type}</Tag>}
|
||||
style={{ marginBottom: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.05)' }}
|
||||
>
|
||||
<Paragraph style={{ marginBottom: 8 }}>
|
||||
{item.Reason}
|
||||
</Paragraph>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{new Date(item.CreatedAt).toLocaleDateString()}
|
||||
</Text>
|
||||
{/* Кнопка действия (заглушка на будущее) */}
|
||||
<Button size="small" type="link">Исправить</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
48
rmser-view/src/hooks/useOcr.ts
Normal file
48
rmser-view/src/hooks/useOcr.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../services/api';
|
||||
import type { MatchRequest, ProductMatch, CatalogItem, UnmatchedItem } from '../services/types';
|
||||
import { message } from 'antd';
|
||||
|
||||
export const useOcr = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const catalogQuery = useQuery<CatalogItem[], Error>({
|
||||
queryKey: ['catalog'],
|
||||
queryFn: api.getCatalogItems,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
|
||||
const matchesQuery = useQuery<ProductMatch[], Error>({
|
||||
queryKey: ['matches'],
|
||||
queryFn: api.getMatches,
|
||||
});
|
||||
|
||||
const unmatchedQuery = useQuery<UnmatchedItem[], Error>({
|
||||
queryKey: ['unmatched'],
|
||||
queryFn: api.getUnmatched,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const createMatchMutation = useMutation({
|
||||
// Теперь типы совпадают, any не нужен
|
||||
mutationFn: (newMatch: MatchRequest) => api.createMatch(newMatch),
|
||||
onSuccess: () => {
|
||||
message.success('Связь сохранена');
|
||||
queryClient.invalidateQueries({ queryKey: ['matches'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['unmatched'] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error('Ошибка при сохранении');
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
catalog: catalogQuery.data || [],
|
||||
matches: matchesQuery.data || [],
|
||||
unmatched: unmatchedQuery.data || [],
|
||||
isLoading: catalogQuery.isPending || matchesQuery.isPending,
|
||||
isError: catalogQuery.isError || matchesQuery.isError,
|
||||
createMatch: createMatchMutation.mutate,
|
||||
isCreating: createMatchMutation.isPending,
|
||||
};
|
||||
};
|
||||
12
rmser-view/src/hooks/useRecommendations.ts
Normal file
12
rmser-view/src/hooks/useRecommendations.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../services/api';
|
||||
import type { Recommendation } from '../services/types';
|
||||
|
||||
export const useRecommendations = () => {
|
||||
return useQuery<Recommendation[], Error>({
|
||||
queryKey: ['recommendations'],
|
||||
queryFn: api.getRecommendations,
|
||||
// Обновлять данные каждые 30 секунд, если вкладка активна
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
};
|
||||
12
rmser-view/src/main.tsx
Normal file
12
rmser-view/src/main.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
// Если есть глобальные стили, они подключаются тут.
|
||||
// Если файла index.css нет, убери эту строку.
|
||||
// import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
59
rmser-view/src/pages/Dashboard.tsx
Normal file
59
rmser-view/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Typography, Row, Col, Statistic, Spin, Alert, Empty } from 'antd';
|
||||
import { useRecommendations } from '../hooks/useRecommendations';
|
||||
import { RecommendationCard } from '../components/recommendations/RecommendationCard';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export const Dashboard: React.FC = () => {
|
||||
const { data: recommendations, isPending, isError, error } = useRecommendations();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
||||
<Spin size="large" tip="Загрузка аналитики..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert
|
||||
message="Ошибка загрузки"
|
||||
description={error?.message || 'Не удалось получить данные с сервера'}
|
||||
type="error"
|
||||
showIcon
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Группировка для статистики
|
||||
const unusedCount = recommendations?.filter(r => r.Type === 'UNUSED_IN_RECIPES').length || 0;
|
||||
const noIncomingCount = recommendations?.filter(r => r.Type === 'NO_INCOMING').length || 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4} style={{ marginTop: 0 }}>Сводка проблем</Title>
|
||||
|
||||
{/* Блок статистики */}
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={12}>
|
||||
<Statistic title="Без техкарт" value={unusedCount} valueStyle={{ color: '#cf1322' }} />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic title="Без закупок" value={noIncomingCount} valueStyle={{ color: '#d48806' }} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Title level={5}>Рекомендации ({recommendations?.length})</Title>
|
||||
|
||||
{recommendations && recommendations.length > 0 ? (
|
||||
recommendations.map((rec) => (
|
||||
<RecommendationCard key={rec.ID} item={rec} />
|
||||
))
|
||||
) : (
|
||||
<Empty description="Проблем не обнаружено" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
52
rmser-view/src/pages/OcrLearning.tsx
Normal file
52
rmser-view/src/pages/OcrLearning.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { Spin, Alert } from 'antd';
|
||||
import { useOcr } from '../hooks/useOcr';
|
||||
import { AddMatchForm } from '../components/ocr/AddMatchForm';
|
||||
import { MatchList } from '../components/ocr/MatchList';
|
||||
|
||||
export const OcrLearning: React.FC = () => {
|
||||
const {
|
||||
catalog,
|
||||
matches,
|
||||
unmatched,
|
||||
isLoading,
|
||||
isError,
|
||||
createMatch,
|
||||
isCreating
|
||||
} = useOcr();
|
||||
|
||||
if (isLoading && matches.length === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '50vh', flexDirection: 'column', gap: 16 }}>
|
||||
<Spin size="large" />
|
||||
<span style={{ color: '#888' }}>Загрузка справочников...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert message="Ошибка" description="Не удалось загрузить данные." type="error" showIcon style={{ margin: 16 }} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ paddingBottom: 20 }}>
|
||||
<AddMatchForm
|
||||
catalog={catalog}
|
||||
unmatched={unmatched}
|
||||
// Передаем containerId
|
||||
onSave={(raw, prodId, qty, contId) => createMatch({
|
||||
raw_name: raw,
|
||||
product_id: prodId,
|
||||
quantity: qty,
|
||||
container_id: contId
|
||||
})}
|
||||
isLoading={isCreating}
|
||||
/>
|
||||
|
||||
<h3 style={{ marginLeft: 4, marginBottom: 12 }}>Обученные позиции ({matches.length})</h3>
|
||||
<MatchList matches={matches} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
67
rmser-view/src/services/api.ts
Normal file
67
rmser-view/src/services/api.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
CatalogItem,
|
||||
CreateInvoiceRequest,
|
||||
MatchRequest, // Используем новый тип
|
||||
HealthResponse,
|
||||
InvoiceResponse,
|
||||
ProductMatch,
|
||||
Recommendation,
|
||||
UnmatchedItem
|
||||
} from './types';
|
||||
|
||||
// Базовый URL
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
console.error('API Error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export const api = {
|
||||
checkHealth: async (): Promise<HealthResponse> => {
|
||||
const { data } = await apiClient.get<HealthResponse>('/health');
|
||||
return data;
|
||||
},
|
||||
|
||||
getRecommendations: async (): Promise<Recommendation[]> => {
|
||||
const { data } = await apiClient.get<Recommendation[]>('/recommendations');
|
||||
return data;
|
||||
},
|
||||
|
||||
getCatalogItems: async (): Promise<CatalogItem[]> => {
|
||||
const { data } = await apiClient.get<CatalogItem[]>('/ocr/catalog');
|
||||
return data;
|
||||
},
|
||||
|
||||
getMatches: async (): Promise<ProductMatch[]> => {
|
||||
const { data } = await apiClient.get<ProductMatch[]>('/ocr/matches');
|
||||
return data;
|
||||
},
|
||||
|
||||
getUnmatched: async (): Promise<UnmatchedItem[]> => {
|
||||
const { data } = await apiClient.get<UnmatchedItem[]>('/ocr/unmatched');
|
||||
return data;
|
||||
},
|
||||
|
||||
// Обновили тип аргумента payload
|
||||
createMatch: async (payload: MatchRequest): Promise<{ status: string }> => {
|
||||
const { data } = await apiClient.post<{ status: string }>('/ocr/match', payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
createInvoice: async (payload: CreateInvoiceRequest): Promise<InvoiceResponse> => {
|
||||
const { data } = await apiClient.post<InvoiceResponse>('/invoices/send', payload);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
98
rmser-view/src/services/types.ts
Normal file
98
rmser-view/src/services/types.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// --- Общие типы ---
|
||||
|
||||
export type UUID = string;
|
||||
|
||||
// --- Каталог и Фасовки (API v2.0) ---
|
||||
|
||||
export interface ProductContainer {
|
||||
id: UUID;
|
||||
name: string; // "Пачка 180г"
|
||||
count: number; // 0.180
|
||||
}
|
||||
|
||||
export interface CatalogItem {
|
||||
// Основные поля (snake_case)
|
||||
id: UUID;
|
||||
name: string;
|
||||
code: string;
|
||||
measure_unit: string; // "кг", "л"
|
||||
containers: ProductContainer[]; // Массив фасовок
|
||||
|
||||
// Fallback (на всякий случай)
|
||||
ID?: UUID;
|
||||
Name?: string;
|
||||
Code?: string;
|
||||
MeasureUnit?: string;
|
||||
Containers?: ProductContainer[];
|
||||
}
|
||||
|
||||
// --- Матчинг (Обучение) ---
|
||||
|
||||
export interface MatchRequest {
|
||||
raw_name: string;
|
||||
product_id: UUID;
|
||||
quantity: number;
|
||||
container_id?: UUID; // Новое поле
|
||||
}
|
||||
|
||||
export interface ProductMatch {
|
||||
// snake_case (v2.0)
|
||||
raw_name: string;
|
||||
product_id: UUID;
|
||||
product?: CatalogItem;
|
||||
quantity: number;
|
||||
container_id?: UUID;
|
||||
container?: ProductContainer;
|
||||
updated_at: string;
|
||||
|
||||
// Fallback
|
||||
RawName?: string;
|
||||
ProductID?: UUID;
|
||||
Product?: CatalogItem;
|
||||
Quantity?: number;
|
||||
ContainerId?: UUID;
|
||||
Container?: ProductContainer;
|
||||
}
|
||||
|
||||
// --- Нераспознанное ---
|
||||
|
||||
export interface UnmatchedItem {
|
||||
raw_name: string;
|
||||
count: number;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
// --- Остальные типы (без изменений) ---
|
||||
|
||||
export interface Recommendation {
|
||||
ID: UUID;
|
||||
Type: string;
|
||||
ProductID: UUID;
|
||||
ProductName: string;
|
||||
Reason: string;
|
||||
CreatedAt: string;
|
||||
}
|
||||
|
||||
export interface InvoiceItemRequest {
|
||||
product_id: UUID;
|
||||
amount: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface CreateInvoiceRequest {
|
||||
document_number: string;
|
||||
date_incoming: string;
|
||||
supplier_id: UUID;
|
||||
store_id: UUID;
|
||||
items: InvoiceItemRequest[];
|
||||
}
|
||||
|
||||
export interface InvoiceResponse {
|
||||
status: string;
|
||||
created_number: string;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
time: string;
|
||||
}
|
||||
Reference in New Issue
Block a user