mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
Перевел на multi-tenant
Добавил поставщиков Накладные успешно создаются из фронта
This commit is contained in:
@@ -1,24 +1,57 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Result, Button } from 'antd';
|
||||
import { Providers } from './components/layout/Providers';
|
||||
import { AppLayout } from './components/layout/AppLayout';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { OcrLearning } from './pages/OcrLearning';
|
||||
import { InvoiceDraftPage } from './pages/InvoiceDraftPage';
|
||||
import { DraftsList } from './pages/DraftsList';
|
||||
import { UNAUTHORIZED_EVENT } from './services/api';
|
||||
|
||||
// Компонент заглушки для 401 ошибки
|
||||
const UnauthorizedScreen = () => (
|
||||
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fff' }}>
|
||||
<Result
|
||||
status="403"
|
||||
title="Доступ запрещен"
|
||||
subTitle="Мы не нашли вас в базе данных. Пожалуйста, запустите бота и настройте сервер."
|
||||
extra={
|
||||
<Button type="primary" href="https://t.me/RmserBot" target="_blank">
|
||||
Перейти в бота @RmserBot
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
function App() {
|
||||
const [isUnauthorized, setIsUnauthorized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleUnauthorized = () => setIsUnauthorized(true);
|
||||
|
||||
// Подписываемся на событие из api.ts
|
||||
window.addEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isUnauthorized) {
|
||||
return <UnauthorizedScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Providers>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
{/* Если Dashboard удален, можно сделать редирект на invoices */}
|
||||
<Route index element={<Navigate to="/invoices" replace />} />
|
||||
|
||||
<Route path="ocr" element={<OcrLearning />} />
|
||||
|
||||
{/* Список черновиков */}
|
||||
<Route path="invoices" element={<DraftsList />} />
|
||||
|
||||
{/* Редактирование черновика */}
|
||||
<Route path="invoice/:id" element={<InvoiceDraftPage />} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -24,8 +24,14 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// --- ЗАПРОСЫ ---
|
||||
const storesQuery = useQuery({ queryKey: ['stores'], queryFn: api.getStores });
|
||||
const suppliersQuery = useQuery({ queryKey: ['suppliers'], queryFn: api.getSuppliers });
|
||||
|
||||
// Получаем сразу все справочники одним запросом
|
||||
const dictQuery = useQuery({
|
||||
queryKey: ['dictionaries'],
|
||||
queryFn: api.getDictionaries,
|
||||
staleTime: 1000 * 60 * 5 // Кэшируем на 5 минут
|
||||
});
|
||||
|
||||
const recommendationsQuery = useQuery({ queryKey: ['recommendations'], queryFn: api.getRecommendations });
|
||||
|
||||
const draftQuery = useQuery({
|
||||
@@ -39,8 +45,9 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
});
|
||||
|
||||
const draft = draftQuery.data;
|
||||
const stores = dictQuery.data?.stores || [];
|
||||
const suppliers = dictQuery.data?.suppliers || [];
|
||||
|
||||
// ... (МУТАЦИИ оставляем без изменений) ...
|
||||
const updateItemMutation = useMutation({
|
||||
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
|
||||
api.updateDraftItem(id!, vars.itemId, vars.payload),
|
||||
@@ -115,11 +122,14 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
|
||||
const handleCommit = async () => {
|
||||
try {
|
||||
// Валидируем форму (включая нового поставщика)
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (invalidItemsCount > 0) {
|
||||
message.warning(`Осталось ${invalidItemsCount} нераспознанных товаров!`);
|
||||
return;
|
||||
}
|
||||
|
||||
commitMutation.mutate({
|
||||
date_incoming: values.date_incoming.format('YYYY-MM-DD'),
|
||||
store_id: values.store_id,
|
||||
@@ -127,7 +137,7 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
comment: values.comment || '',
|
||||
});
|
||||
} catch {
|
||||
message.error('Заполните обязательные поля');
|
||||
message.error('Заполните обязательные поля (Склад, Поставщик)');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -162,12 +172,11 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ paddingBottom: 60 }}>
|
||||
{/* Header: Уплотненный, без переноса слов */}
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/invoices')} size="small" />
|
||||
|
||||
{/* Контейнер заголовка и бейджа */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 'bold', whiteSpace: 'nowrap' }}>
|
||||
{draft.document_number ? `№${draft.document_number}` : 'Черновик'}
|
||||
@@ -189,7 +198,7 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Form: Compact margins */}
|
||||
{/* Form: Склады и Поставщики */}
|
||||
<div style={{ background: '#fff', padding: 12, borderRadius: 8, marginBottom: 12, opacity: isCanceled ? 0.6 : 1 }}>
|
||||
<Form form={form} layout="vertical" initialValues={{ date_incoming: dayjs() }}>
|
||||
<Row gutter={10}>
|
||||
@@ -199,22 +208,27 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="Склад" name="store_id" rules={[{ required: true }]} style={{ marginBottom: 8 }}>
|
||||
<Form.Item label="Склад" name="store_id" rules={[{ required: true, message: 'Выберите склад' }]} style={{ marginBottom: 8 }}>
|
||||
<Select
|
||||
placeholder="Куда?"
|
||||
loading={storesQuery.isLoading}
|
||||
options={storesQuery.data?.map(s => ({ label: s.name, value: s.id }))}
|
||||
loading={dictQuery.isLoading}
|
||||
options={stores.map(s => ({ label: s.name, value: s.id }))}
|
||||
size="middle"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item label="Поставщик" name="supplier_id" rules={[{ required: true }]} style={{ marginBottom: 8 }}>
|
||||
{/* Поле Поставщика (Обязательное) */}
|
||||
<Form.Item label="Поставщик" name="supplier_id" rules={[{ required: true, message: 'Выберите поставщика' }]} style={{ marginBottom: 8 }}>
|
||||
<Select
|
||||
placeholder="От кого?"
|
||||
loading={suppliersQuery.isLoading}
|
||||
options={suppliersQuery.data?.map(s => ({ label: s.name, value: s.id }))}
|
||||
loading={dictQuery.isLoading}
|
||||
options={suppliers.map(s => ({ label: s.name, value: s.id }))}
|
||||
size="middle"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Комментарий" name="comment" style={{ marginBottom: 0 }}>
|
||||
@@ -243,14 +257,14 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<Affix offsetBottom={60} /* Высота нижнего меню */>
|
||||
<Affix offsetBottom={60}>
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
padding: '8px 16px',
|
||||
borderTop: '1px solid #eee',
|
||||
boxShadow: '0 -2px 10px rgba(0,0,0,0.05)',
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
borderRadius: '8px 8px 0 0' // Скругление сверху
|
||||
borderRadius: '8px 8px 0 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{ fontSize: 11, color: '#888', lineHeight: 1 }}>Итого:</span>
|
||||
|
||||
@@ -13,15 +13,25 @@ import type {
|
||||
DraftInvoice,
|
||||
UpdateDraftItemRequest,
|
||||
CommitDraftRequest,
|
||||
// Новые типы
|
||||
ProductSearchResult,
|
||||
AddContainerRequest,
|
||||
AddContainerResponse
|
||||
AddContainerResponse,
|
||||
DictionariesResponse,
|
||||
DraftSummary
|
||||
} from './types';
|
||||
|
||||
// Базовый URL
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api';
|
||||
|
||||
// Телеграм объект
|
||||
const tg = window.Telegram?.WebApp;
|
||||
|
||||
// ID для локальной разработки (Fallback)
|
||||
const DEBUG_USER_ID = 665599275;
|
||||
|
||||
// Событие для глобальной обработки 401
|
||||
export const UNAUTHORIZED_EVENT = 'rms_unauthorized';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: {
|
||||
@@ -29,33 +39,36 @@ const apiClient = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// --- Request Interceptor (Авторизация) ---
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
// 1. Пробуем взять ID из Telegram WebApp
|
||||
// 2. Ищем в URL параметрах (удобно для тестов в браузере: ?_tg_id=123)
|
||||
// 3. Используем хардкод для локальной разработки
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const paramId = urlParams.get('_tg_id');
|
||||
|
||||
const userId = tg?.initDataUnsafe?.user?.id || paramId || DEBUG_USER_ID;
|
||||
|
||||
if (userId) {
|
||||
config.headers['X-Telegram-User-ID'] = userId.toString();
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// --- Response Interceptor (Обработка ошибок) ---
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
// Генерируем кастомное событие, которое поймает App.tsx
|
||||
window.dispatchEvent(new Event(UNAUTHORIZED_EVENT));
|
||||
}
|
||||
console.error('API Error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Мок поставщиков
|
||||
const MOCK_SUPPLIERS: Supplier[] = [
|
||||
{ id: '00000000-0000-0000-0000-000000000001', name: 'ООО "Рога и Копыта"' },
|
||||
{ id: '00000000-0000-0000-0000-000000000002', name: 'ИП Иванов (Овощи)' },
|
||||
{ id: '00000000-0000-0000-0000-000000000003', name: 'Metro Cash&Carry' },
|
||||
{ id: '00000000-0000-0000-0000-000000000004', name: 'Simple Wine' },
|
||||
];
|
||||
|
||||
// интерфейс для списка (краткий)
|
||||
export interface DraftSummary {
|
||||
id: string;
|
||||
document_number: string;
|
||||
date_incoming: string;
|
||||
status: string;
|
||||
items_count: number;
|
||||
total_sum: number;
|
||||
store_name?: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
checkHealth: async (): Promise<HealthResponse> => {
|
||||
const { data } = await apiClient.get<HealthResponse>('/health');
|
||||
@@ -67,14 +80,11 @@ export const api = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// Оставляем для совместимости со старыми компонентами (если используются),
|
||||
// но в Draft Flow будем использовать поиск.
|
||||
getCatalogItems: async (): Promise<CatalogItem[]> => {
|
||||
const { data } = await apiClient.get<CatalogItem[]>('/ocr/catalog');
|
||||
return data;
|
||||
},
|
||||
|
||||
// Поиск товаров ---
|
||||
searchProducts: async (query: string): Promise<ProductSearchResult[]> => {
|
||||
const { data } = await apiClient.get<ProductSearchResult[]>('/ocr/search', {
|
||||
params: { q: query }
|
||||
@@ -82,9 +92,7 @@ export const api = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// Создание фасовки ---
|
||||
createContainer: async (payload: AddContainerRequest): Promise<AddContainerResponse> => {
|
||||
// Внимание: URL эндпоинта взят из вашего ТЗ (/drafts/container)
|
||||
const { data } = await apiClient.post<AddContainerResponse>('/drafts/container', payload);
|
||||
return data;
|
||||
},
|
||||
@@ -109,15 +117,28 @@ export const api = {
|
||||
return data;
|
||||
},
|
||||
|
||||
getStores: async (): Promise<Store[]> => {
|
||||
const { data } = await apiClient.get<Store[]>('/dictionaries/stores');
|
||||
// --- НОВЫЙ МЕТОД: Получение всех справочников ---
|
||||
getDictionaries: async (): Promise<DictionariesResponse> => {
|
||||
const { data } = await apiClient.get<DictionariesResponse>('/dictionaries');
|
||||
return data;
|
||||
},
|
||||
|
||||
// Старые методы оставляем для совместимости, но они могут вызывать getDictionaries внутри или deprecated endpoint
|
||||
getStores: async (): Promise<Store[]> => {
|
||||
// Можно использовать новый эндпоинт и возвращать часть данных
|
||||
const { data } = await apiClient.get<DictionariesResponse>('/dictionaries');
|
||||
return data.stores;
|
||||
},
|
||||
|
||||
getSuppliers: async (): Promise<Supplier[]> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve(MOCK_SUPPLIERS), 300);
|
||||
});
|
||||
// Реальный запрос вместо мока
|
||||
const { data } = await apiClient.get<DictionariesResponse>('/dictionaries');
|
||||
return data.suppliers;
|
||||
},
|
||||
|
||||
getDrafts: async (): Promise<DraftSummary[]> => {
|
||||
const { data } = await apiClient.get<DraftSummary[]>('/drafts');
|
||||
return data;
|
||||
},
|
||||
|
||||
getDraft: async (id: string): Promise<DraftInvoice> => {
|
||||
@@ -125,12 +146,6 @@ export const api = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// Получить список черновиков
|
||||
getDrafts: async (): Promise<DraftSummary[]> => {
|
||||
const { data } = await apiClient.get<DraftSummary[]>('/drafts');
|
||||
return data;
|
||||
},
|
||||
|
||||
updateDraftItem: async (draftId: string, itemId: string, payload: UpdateDraftItemRequest): Promise<DraftInvoice> => {
|
||||
const { data } = await apiClient.patch<DraftInvoice>(`/drafts/${draftId}/items/${itemId}`, payload);
|
||||
return data;
|
||||
@@ -140,8 +155,7 @@ export const api = {
|
||||
const { data } = await apiClient.post<{ document_number: string }>(`/drafts/${draftId}/commit`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Отменить/Удалить черновик
|
||||
|
||||
deleteDraft: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/drafts/${id}`);
|
||||
},
|
||||
|
||||
@@ -121,6 +121,11 @@ export interface Supplier {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DictionariesResponse {
|
||||
stores: Store[];
|
||||
suppliers: Supplier[];
|
||||
}
|
||||
|
||||
// --- Черновик Накладной (Draft) ---
|
||||
|
||||
export type DraftStatus = 'PROCESSING' | 'READY_TO_VERIFY' | 'COMPLETED' | 'ERROR' | 'CANCELED';
|
||||
@@ -146,6 +151,18 @@ export interface DraftItem {
|
||||
container?: ProductContainer; // Развернутый объект для UI
|
||||
}
|
||||
|
||||
// --- Список Черновиков (Summary) ---
|
||||
export interface DraftSummary {
|
||||
id: UUID;
|
||||
document_number: string;
|
||||
date_incoming: string;
|
||||
status: DraftStatus; // Используем существующий тип статуса
|
||||
items_count: number;
|
||||
total_sum: number;
|
||||
store_name?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DraftInvoice {
|
||||
id: UUID;
|
||||
status: DraftStatus;
|
||||
|
||||
24
rmser-view/src/vite-env.d.ts
vendored
Normal file
24
rmser-view/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface TelegramWebApp {
|
||||
initData: string;
|
||||
initDataUnsafe: {
|
||||
user?: {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
language_code?: string;
|
||||
};
|
||||
// ... другие поля по необходимости
|
||||
};
|
||||
close: () => void;
|
||||
expand: () => void;
|
||||
// ... другие методы
|
||||
}
|
||||
|
||||
interface Window {
|
||||
Telegram?: {
|
||||
WebApp: TelegramWebApp;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user