mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-05 03:12:34 -06:00
remove app from repos
This commit is contained in:
@@ -1,16 +0,0 @@
|
|||||||
|
|
||||||
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
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!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
2488
rmser-app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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>,
|
|
||||||
);
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"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" }]
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowSyntheticDefaultImports": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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/, ''),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user