add front spa-app

This commit is contained in:
2025-11-30 06:31:23 +03:00
parent 714844058f
commit 8aced994de
20 changed files with 3358 additions and 1 deletions

View File

@@ -0,0 +1,16 @@
services:
frontend:
image: node:20-alpine
working_dir: /app
volumes:
- ./:/app
# Исключаем node_modules контейнера, чтобы не конфликтовать с хостом
# (хотя для dev-режима часто работает и сквозное монтирование)
- /app/node_modules
ports:
- "5173:5173"
# Для Windows/Mac, чтобы видеть бэкенд на хосте
extra_hosts:
- "host.docker.internal:host-gateway"
command: npm run dev

12
rmser-app/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RMSER App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2488
rmser-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
rmser-app/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "rmser-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@headlessui/react": "^1.7.17",
"@tanstack/react-query": "^5.0.0",
"axios": "^1.6.0",
"clsx": "^2.0.0",
"lucide-react": "^0.292.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.18.0",
"tailwind-merge": "^2.0.0"
},
"devDependencies": {
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react": "^4.0.3",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"typescript": "^5.0.2",
"vite": "^4.4.5"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

17
rmser-app/src/App.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { Routes, Route } from 'react-router-dom';
import { Layout } from './components/Layout';
import { Dashboard } from './pages/Dashboard';
import { OcrTraining } from './pages/OcrTraining';
function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="ocr" element={<OcrTraining />} />
</Route>
</Routes>
);
}
export default App;

View File

@@ -0,0 +1,17 @@
import axios from 'axios';
export const apiClient = axios.create({
baseURL: '/api', // Vite прокси перенаправит это на :8080
headers: {
'Content-Type': 'application/json',
},
});
// Интерцептор для обработки ошибок (опционально)
apiClient.interceptors.response.use(
(response) => response,
(error) => {
console.error('API Error:', error.response?.data || error.message);
return Promise.reject(error);
}
);

View File

@@ -0,0 +1,51 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from './client';
import { CatalogItem, ProductMatch, Recommendation, CreateMatchRequest } from './types';
// --- Recommendations ---
export const useRecommendations = () => {
return useQuery({
queryKey: ['recommendations'],
queryFn: async () => {
const { data } = await apiClient.get<Recommendation[]>('/recommendations');
return data;
},
});
};
// --- OCR ---
export const useCatalog = () => {
return useQuery({
queryKey: ['catalog'],
queryFn: async () => {
const { data } = await apiClient.get<CatalogItem[]>('/ocr/catalog');
return data;
},
staleTime: 1000 * 60 * 5, // Каталог меняется редко, кэшируем на 5 минут
});
};
export const useMatches = () => {
return useQuery({
queryKey: ['matches'],
queryFn: async () => {
const { data } = await apiClient.get<ProductMatch[]>('/ocr/matches');
return data;
},
});
};
export const useCreateMatch = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: CreateMatchRequest) => {
const { data } = await apiClient.post('/ocr/match', payload);
return data;
},
onSuccess: () => {
// Обновляем список матчей после добавления
queryClient.invalidateQueries({ queryKey: ['matches'] });
},
});
};

View File

@@ -0,0 +1,30 @@
export type UUID = string;
// --- Каталог и OCR ---
export interface CatalogItem {
id: UUID;
name: string;
code: string;
}
export interface ProductMatch {
raw_name: string;
product_id: UUID;
product?: CatalogItem;
updated_at: string;
}
export interface CreateMatchRequest {
raw_name: string;
product_id: UUID;
}
// --- Рекомендации ---
export interface Recommendation {
ID: UUID;
Type: string;
ProductID: UUID;
ProductName: string;
Reason: string;
CreatedAt: string;
}

View File

@@ -0,0 +1,57 @@
import { Link, Outlet, useLocation } from 'react-router-dom';
import { LayoutDashboard, BrainCircuit, Menu } from 'lucide-react';
import clsx from 'clsx';
const NAV_ITEMS = [
{ label: 'Аналитика', path: '/', icon: LayoutDashboard },
{ label: 'Обучение OCR', path: '/ocr', icon: BrainCircuit },
];
export const Layout = () => {
const location = useLocation();
return (
<div className="min-h-screen bg-slate-50 flex flex-col md:flex-row font-sans text-slate-900">
{/* Sidebar */}
<aside className="w-full md:w-64 bg-white border-r border-slate-200 flex-shrink-0">
<div className="h-16 flex items-center px-6 border-b border-slate-100">
<span className="text-xl font-bold text-indigo-600">RMSER</span>
<span className="ml-2 text-xs bg-indigo-50 text-indigo-700 px-2 py-0.5 rounded">v1.0</span>
</div>
<nav className="p-4 space-y-1">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.path;
return (
<Link
key={item.path}
to={item.path}
className={clsx(
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium transition-colors",
isActive
? "bg-indigo-50 text-indigo-700"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900"
)}
>
<Icon size={18} />
{item.label}
</Link>
);
})}
</nav>
</aside>
{/* Main Content */}
<main className="flex-1 overflow-auto">
<header className="h-16 bg-white border-b border-slate-200 flex items-center px-6 md:hidden">
<Menu className="text-slate-500" />
<span className="ml-3 font-semibold">Меню</span>
</header>
<div className="p-4 md:p-8 max-w-7xl mx-auto">
<Outlet />
</div>
</main>
</div>
);
};

View File

@@ -0,0 +1,87 @@
import { useState, useMemo } from 'react';
import { CatalogItem } from '../api/types';
import { ChevronsUpDown, Check } from 'lucide-react';
import clsx from 'clsx';
interface Props {
items: CatalogItem[];
value: string | null; // ID выбранного товара
onChange: (id: string) => void;
isLoading?: boolean;
}
export const ProductSelect = ({ items, value, onChange, isLoading }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const selectedItem = items.find((i) => i.id === value);
const filteredItems = useMemo(() => {
if (!query) return items.slice(0, 50); // Показываем первые 50 для скорости
const lowerQuery = query.toLowerCase();
return items.filter((item) =>
item.name.toLowerCase().includes(lowerQuery) ||
item.code.includes(lowerQuery)
).slice(0, 50);
}, [items, query]);
return (
<div className="relative w-full">
<div
className="flex items-center justify-between w-full px-3 py-2 text-sm bg-white border rounded-md border-slate-300 cursor-pointer focus-within:ring-2 focus-within:ring-indigo-500 focus-within:border-transparent"
onClick={() => setIsOpen(!isOpen)}
>
<span className={clsx("block truncate", !selectedItem && "text-slate-400")}>
{selectedItem ? selectedItem.name : "Выберите товар..."}
</span>
<ChevronsUpDown className="w-4 h-4 opacity-50" />
</div>
{isOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
<div className="absolute z-20 w-full mt-1 bg-white border border-slate-200 rounded-md shadow-lg max-h-60 overflow-auto">
<div className="p-2 sticky top-0 bg-white border-b">
<input
type="text"
className="w-full px-2 py-1 text-sm border rounded bg-slate-50 focus:outline-none focus:ring-1 focus:ring-indigo-500"
placeholder="Поиск по названию или коду..."
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
</div>
{isLoading ? (
<div className="p-4 text-center text-sm text-slate-500">Загрузка каталога...</div>
) : filteredItems.length === 0 ? (
<div className="p-4 text-center text-sm text-slate-500">Ничего не найдено</div>
) : (
<ul>
{filteredItems.map((item) => (
<li
key={item.id}
className={clsx(
"px-3 py-2 text-sm cursor-pointer hover:bg-indigo-50 flex items-center justify-between",
item.id === value && "bg-indigo-50 text-indigo-700 font-medium"
)}
onClick={() => {
onChange(item.id);
setIsOpen(false);
setQuery('');
}}
>
<span>{item.name}</span>
{item.id === value && <Check className="w-4 h-4" />}
</li>
))}
</ul>
)}
</div>
</>
)}
</div>
);
};

25
rmser-app/src/main.tsx Normal file
View File

@@ -0,0 +1,25 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './index.css'; // Tailwind directives here
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false, // Отключаем лишние запросы при переключении вкладок
retry: 1,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,88 @@
import { useMemo } from 'react';
import { useRecommendations } from '../api/queries';
import { AlertCircle, PackageX, FileWarning } from 'lucide-react';
import clsx from 'clsx';
// Маппинг типов ошибок на заголовки и цвета (для красоты)
const TYPE_CONFIG: Record<string, { label: string; color: string; icon: any }> = {
UNUSED_IN_RECIPES: {
label: 'Товары без техкарт',
color: 'bg-amber-50 text-amber-700 border-amber-200',
icon: FileWarning
},
NO_INCOMING: {
label: 'Ингредиенты без закупок',
color: 'bg-red-50 text-red-700 border-red-200',
icon: PackageX
},
// Default
DEFAULT: {
label: 'Прочие предупреждения',
color: 'bg-slate-50 text-slate-700 border-slate-200',
icon: AlertCircle
}
};
export const Dashboard = () => {
const { data: recommendations, isLoading, isError } = useRecommendations();
// Группировка данных по Type
const groupedData = useMemo(() => {
if (!recommendations) return {};
return recommendations.reduce((acc, item) => {
const key = item.Type;
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
}, {} as Record<string, typeof recommendations>);
}, [recommendations]);
if (isLoading) return <div className="p-8 text-center text-slate-500">Загрузка аналитики...</div>;
if (isError) return <div className="p-8 text-center text-red-500">Ошибка загрузки данных</div>;
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold text-slate-900">Дашборд аналитики</h1>
<p className="text-slate-500">Проблемы учета и рекомендации по складу</p>
</div>
{Object.entries(groupedData).map(([type, items]) => {
const config = TYPE_CONFIG[type] || TYPE_CONFIG.DEFAULT;
const Icon = config.icon;
return (
<div key={type} className="space-y-4">
<div className="flex items-center gap-2">
<Icon className={clsx("w-5 h-5", config.color.split(' ')[1])} />
<h2 className="text-lg font-semibold text-slate-800">
{config.label} <span className="text-slate-400 text-sm ml-1">({items.length})</span>
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((rec) => (
<div
key={rec.ID}
className="bg-white p-4 rounded-lg border border-slate-200 shadow-sm hover:shadow-md transition-shadow"
>
<div className="font-medium text-slate-900 mb-1">{rec.ProductName}</div>
<div className="text-sm text-slate-600 mb-3">{rec.Reason}</div>
<div className="text-xs text-slate-400">
Обнаружено: {new Date(rec.CreatedAt).toLocaleDateString()}
</div>
</div>
))}
</div>
</div>
);
})}
{(!recommendations || recommendations.length === 0) && (
<div className="text-center py-10 bg-white rounded-lg border border-slate-200">
<p className="text-green-600 font-medium">Отлично! Проблем не обнаружено.</p>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,130 @@
import { useState } from 'react';
import { useCatalog, useMatches, useCreateMatch } from '../api/queries';
import { ProductSelect } from '../components/ProductSelect';
import { Plus, Save, Loader2 } from 'lucide-react';
import clsx from 'clsx';
export const OcrTraining = () => {
// Queries
const { data: matches, isLoading: loadingMatches } = useMatches();
const { data: catalog, isLoading: loadingCatalog } = useCatalog();
const createMatchMutation = useCreateMatch();
// Form State
const [rawName, setRawName] = useState('');
const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!rawName || !selectedProductId) return;
createMatchMutation.mutate(
{ raw_name: rawName, product_id: selectedProductId },
{
onSuccess: () => {
setRawName('');
setSelectedProductId(null);
}
}
);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Обучение OCR</h1>
<p className="text-slate-500">Связывание наименований из чеков с номенклатурой iiko</p>
</div>
{/* Форма добавления */}
<div className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
<h3 className="text-sm font-semibold text-slate-900 uppercase tracking-wider mb-4 flex items-center gap-2">
<Plus className="w-4 h-4" /> Добавить правило
</h3>
<form onSubmit={handleSubmit} className="flex flex-col md:flex-row gap-4 items-end">
<div className="w-full md:w-1/2 space-y-1.5">
<label className="text-sm font-medium text-slate-700">Текст из чека (Raw)</label>
<input
type="text"
value={rawName}
onChange={(e) => setRawName(e.target.value)}
placeholder="Например: Молоко 3.2 петмол"
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div className="w-full md:w-1/2 space-y-1.5">
<label className="text-sm font-medium text-slate-700">Товар в iiko</label>
<ProductSelect
items={catalog || []}
value={selectedProductId}
onChange={setSelectedProductId}
isLoading={loadingCatalog}
/>
</div>
<button
type="submit"
disabled={!rawName || !selectedProductId || createMatchMutation.isPending}
className={clsx(
"px-4 py-2 rounded-md text-sm font-medium text-white transition-colors flex items-center gap-2 min-w-[120px] justify-center",
(!rawName || !selectedProductId)
? "bg-slate-300 cursor-not-allowed"
: "bg-indigo-600 hover:bg-indigo-700"
)}
>
{createMatchMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Сохранить
</button>
</form>
{createMatchMutation.isError && (
<p className="mt-2 text-sm text-red-500">Ошибка при сохранении. Возможно, такая связь уже есть.</p>
)}
</div>
{/* Таблица существующих связей */}
<div className="bg-white border border-slate-200 rounded-lg shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200 bg-slate-50">
<h3 className="font-semibold text-slate-800">Активные связи</h3>
</div>
{loadingMatches ? (
<div className="p-8 text-center text-slate-500">Загрузка данных...</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-slate-50 text-slate-500 font-medium">
<tr>
<th className="px-6 py-3">Текст из чека</th>
<th className="px-6 py-3">Товар iiko</th>
<th className="px-6 py-3">Дата обновления</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{matches?.map((match) => (
<tr key={match.raw_name} className="hover:bg-slate-50">
<td className="px-6 py-3 font-medium text-slate-900">{match.raw_name}</td>
<td className="px-6 py-3 text-indigo-600">
{match.product?.name || match.product_id}
</td>
<td className="px-6 py-3 text-slate-400">
{new Date(match.updated_at).toLocaleDateString()}
</td>
</tr>
))}
{matches?.length === 0 && (
<tr>
<td colSpan={3} className="px-6 py-8 text-center text-slate-500">
Список пуст. Добавьте первое правило обучения.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

21
rmser-app/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

17
rmser-app/vite.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://host.docker.internal:8080',
changeOrigin: true,
// Если бэкенд ожидает /api, rewrite не нужен.
// Если бэкенд на корне (localhost:8080/recommendations), то раскомментируй:
// rewrite: (path) => path.replace(/^\/api/, ''),
}
}
}
})