added front - react+ts

ocr improved
This commit is contained in:
2025-12-11 05:20:53 +03:00
parent 73b1477368
commit 02681340c5
39 changed files with 6286 additions and 267 deletions

3
rmser-view/.env Normal file
View File

@@ -0,0 +1,3 @@
# Используем относительный путь.
# Браузер сам подставит текущий домен: https://rmser.your-domain.com/api
VITE_API_URL=/api

24
rmser-view/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
rmser-view/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

16
rmser-view/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RMSer App</title>
<!-- Скрипт Telegram WebApp (желательно добавить) -->
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
<body>
<div id="root"></div>
<!-- ВОТ ЗДЕСЬ ДОЛЖЕН БЫТЬ ПРАВИЛЬНЫЙ ПУТЬ -->
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4604
rmser-view/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
rmser-view/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "rmser-view",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.90.12",
"@twa-dev/sdk": "^8.0.2",
"antd": "^6.1.0",
"axios": "^1.13.2",
"clsx": "^2.1.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.10.1",
"zustand": "^5.0.9"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

27
rmser-view/src/App.tsx Normal file
View 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;

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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}
/>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View 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,
};
};

View 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
View 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>,
)

View 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>
);
};

View 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>
);
};

View 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;
},
};

View 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;
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
rmser-view/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

13
rmser-view/vite.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173,
// Разрешаем наш домен
allowedHosts: ['rmser.serty.top'],
}
})