Полноценно редактируются черновики

Добавляются фасовки как в черновике, так и в обучении
Исправил внешний вид
This commit is contained in:
2025-12-17 22:00:21 +03:00
parent e2df2350f7
commit c8aab42e8e
24 changed files with 1313 additions and 433 deletions

View File

@@ -3,10 +3,8 @@ 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'; // Импорт
// Заглушки для списка накладных пока оставим (или можно сделать пустую страницу)
const InvoicesListPage = () => <h2>История накладных (в разработке)</h2>;
import { InvoiceDraftPage } from './pages/InvoiceDraftPage';
import { DraftsList } from './pages/DraftsList';
function App() {
return (
@@ -17,11 +15,11 @@ function App() {
<Route index element={<Dashboard />} />
<Route path="ocr" element={<OcrLearning />} />
{/* Роут для черновика. :id - UUID черновика */}
<Route path="invoice/:id" element={<InvoiceDraftPage />} />
{/* Список черновиков */}
<Route path="invoices" element={<DraftsList />} />
{/* Страница списка */}
<Route path="invoices" element={<InvoicesListPage />} />
{/* Редактирование черновика */}
<Route path="invoice/:id" element={<InvoiceDraftPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>

View File

@@ -0,0 +1,84 @@
import React, { useState } from 'react';
import { Modal, Form, Input, InputNumber, Button, message } from 'antd';
import { api } from '../../services/api';
import type { ProductContainer } from '../../services/types';
interface Props {
visible: boolean;
onCancel: () => void;
productId: string;
productBaseUnit: string;
// Callback возвращает уже полный объект с ID от сервера
onSuccess: (container: ProductContainer) => void;
}
export const CreateContainerModal: React.FC<Props> = ({
visible, onCancel, productId, productBaseUnit, onSuccess
}) => {
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const handleOk = async () => {
try {
const values = await form.validateFields();
setLoading(true);
// 1. Отправляем запрос на БЭКЕНД
const res = await api.createContainer({
product_id: productId,
name: values.name,
count: values.count
});
message.success('Фасовка создана');
// 2. БЭКЕНД вернул ID. Теперь мы собираем объект для UI
// Мы не придумываем ID сами, мы берем res.container_id
const newContainer: ProductContainer = {
id: res.container_id, // <--- ID от сервера
name: values.name,
count: values.count
};
// 3. Возвращаем полный объект родителю
onSuccess(newContainer);
form.resetFields();
} catch {
message.error('Ошибка создания фасовки');
} finally {
setLoading(false);
}
};
return (
<Modal
title="Новая фасовка"
open={visible}
onCancel={onCancel}
footer={[
<Button key="back" onClick={onCancel}>Отмена</Button>,
<Button key="submit" type="primary" loading={loading} onClick={handleOk}>
Создать
</Button>,
]}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="Название"
rules={[{ required: true, message: 'Введите название, например 0.5' }]}
>
<Input placeholder="Например: Бутылка 0.5" />
</Form.Item>
<Form.Item
name="count"
label={`Количество в базовых ед. (${productBaseUnit})`}
rules={[{ required: true, message: 'Введите коэффициент' }]}
>
<InputNumber style={{ width: '100%' }} step={0.001} placeholder="0.5" />
</Form.Item>
</Form>
</Modal>
);
};

View File

@@ -1,162 +1,243 @@
import React, { useMemo } from 'react';
import { Card, Flex, InputNumber, Typography, Select, Tag } from 'antd';
import { SyncOutlined } from '@ant-design/icons';
import React, { useMemo, useState, useEffect } from 'react';
import { Card, Flex, InputNumber, Typography, Select, Tag, Button, Divider, Modal } from 'antd';
import { SyncOutlined, PlusOutlined, WarningFilled } from '@ant-design/icons';
import { CatalogSelect } from '../ocr/CatalogSelect';
import type { DraftItem, CatalogItem, UpdateDraftItemRequest } from '../../services/types';
import { CreateContainerModal } from './CreateContainerModal';
import type { DraftItem, UpdateDraftItemRequest, ProductSearchResult, ProductContainer, Recommendation } from '../../services/types';
const { Text } = Typography;
interface Props {
item: DraftItem;
catalog: CatalogItem[];
onUpdate: (itemId: string, changes: UpdateDraftItemRequest) => void;
isUpdating: boolean; // Флаг, что конкретно эта строка сейчас сохраняется
isUpdating: boolean;
recommendations?: Recommendation[]; // Новый проп
}
export const DraftItemRow: React.FC<Props> = ({ item, catalog, onUpdate, isUpdating }) => {
// 1. Поиск выбранного товара в полном каталоге, чтобы получить доступ к containers
const selectedProductObj = useMemo(() => {
if (!item.product_id) return null;
return catalog.find(c => c.id === item.product_id || c.ID === item.product_id);
}, [item.product_id, catalog]);
export const DraftItemRow: React.FC<Props> = ({ item, onUpdate, isUpdating, recommendations = [] }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
// State Input
const [localQuantity, setLocalQuantity] = useState<string | null>(item.quantity?.toString() ?? null);
const [localPrice, setLocalPrice] = useState<string | null>(item.price?.toString() ?? null);
// Sync Effect
useEffect(() => {
const serverQty = item.quantity;
const currentLocal = parseFloat(localQuantity?.replace(',', '.') || '0');
if (Math.abs(serverQty - currentLocal) > 0.001) setLocalQuantity(serverQty.toString().replace('.', ','));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item.quantity]);
useEffect(() => {
const serverPrice = item.price;
const currentLocal = parseFloat(localPrice?.replace(',', '.') || '0');
if (Math.abs(serverPrice - currentLocal) > 0.001) setLocalPrice(serverPrice.toString().replace('.', ','));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item.price]);
// Product Logic
const [searchedProduct, setSearchedProduct] = useState<ProductSearchResult | null>(null);
const [addedContainers, setAddedContainers] = useState<Record<string, ProductContainer[]>>({});
const activeProduct = useMemo(() => {
if (searchedProduct && searchedProduct.id === item.product_id) return searchedProduct;
return item.product as unknown as ProductSearchResult | undefined;
}, [searchedProduct, item.product, item.product_id]);
const containers = useMemo(() => {
if (!activeProduct) return [];
const baseContainers = activeProduct.containers || [];
const manuallyAdded = addedContainers[activeProduct.id] || [];
const combined = [...baseContainers];
manuallyAdded.forEach(c => {
if (!combined.find(existing => existing.id === c.id)) combined.push(c);
});
return combined;
}, [activeProduct, addedContainers]);
const baseUom = activeProduct?.main_unit?.name || activeProduct?.measure_unit || 'ед.';
// 2. Список фасовок для селекта
const containerOptions = useMemo(() => {
if (!selectedProductObj) return [];
const conts = selectedProductObj.containers || selectedProductObj.Containers || [];
const baseUom = selectedProductObj.measure_unit || selectedProductObj.MeasureUnit || 'ед.';
return [
{ value: null, label: `Базовая (${baseUom})` }, // null значит базовая единица
...conts.map(c => ({
if (!activeProduct) return [];
const opts = [
{ value: 'BASE_UNIT', label: `Базовая (${baseUom})` },
...containers.map(c => ({
value: c.id,
label: c.name // "Коробка"
label: `${c.name} (=${Number(c.count)} ${baseUom})`
}))
];
}, [selectedProductObj]);
if (item.container_id && item.container && !containers.find(c => c.id === item.container_id)) {
opts.push({
value: item.container.id,
label: `${item.container.name} (=${Number(item.container.count)} ${baseUom})`
});
}
return opts;
}, [activeProduct, containers, baseUom, item.container_id, item.container]);
// 3. Хендлеры изменений
// --- WARNING LOGIC ---
const activeWarning = useMemo(() => {
if (!item.product_id) return null;
return recommendations.find(r => r.ProductID === item.product_id);
}, [item.product_id, recommendations]);
const handleProductChange = (prodId: string) => {
// При смене товара: сбрасываем фасовку, подставляем исходные кол-во/цену, если они были нулями (логика "default")
// Но по ТЗ: "При выборе товара автоматически подставлять quantity = raw_amount..."
// Это лучше делать, передавая эти данные.
onUpdate(item.id, {
product_id: prodId,
container_id: null, // Сброс фасовки
quantity: item.quantity || item.raw_amount || 1,
price: item.price || item.raw_price || 0
const showWarningModal = () => {
if (!activeWarning) return;
Modal.warning({
title: 'Внимание: проблемный товар',
content: (
<div>
<p><b>{activeWarning.ProductName}</b></p>
<p>{activeWarning.Reason}</p>
<p><Tag color="orange">{activeWarning.Type}</Tag></p>
</div>
),
okText: 'Понятно',
maskClosable: true
});
};
const handleContainerChange = (val: string | null) => {
// При смене фасовки просто шлем ID. Сервер сам не пересчитывает цифры, фронт тоже не должен.
// Пользователь сам поправит цену, если она изменилась за упаковку.
onUpdate(item.id, {
container_id: val || null // Antd Select может вернуть undefined, приводим к null
});
// --- Helpers ---
const parseToNum = (val: string | null | undefined): number => {
if (!val) return 0;
return parseFloat(val.replace(',', '.'));
};
const handleBlur = (field: 'quantity' | 'price', val: number | null) => {
// Сохраняем только если значение изменилось и валидно
if (val === null) return;
if (val === item[field]) return;
const getUpdatePayload = (overrides: Partial<UpdateDraftItemRequest>): UpdateDraftItemRequest => {
const currentQty = localQuantity !== null ? parseToNum(localQuantity) : item.quantity;
const currentPrice = localPrice !== null ? parseToNum(localPrice) : item.price;
onUpdate(item.id, {
[field]: val
});
return {
product_id: item.product_id || undefined,
container_id: item.container_id,
quantity: currentQty ?? 1,
price: currentPrice ?? 0,
...overrides
};
};
// Вычисляем статус цвета
const cardBorderColor = !item.product_id ? '#ffa39e' : item.is_matched ? '#b7eb8f' : '#d9d9d9'; // Красный если нет товара, Зеленый если сматчился сам, Серый если правим
// --- Handlers ---
const handleProductChange = (prodId: string, productObj?: ProductSearchResult) => {
if (productObj) setSearchedProduct(productObj);
onUpdate(item.id, getUpdatePayload({ product_id: prodId, container_id: null }));
};
const handleContainerChange = (val: string) => {
const newVal = val === 'BASE_UNIT' ? null : val;
onUpdate(item.id, getUpdatePayload({ container_id: newVal }));
};
const handleBlur = (field: 'quantity' | 'price') => {
const localVal = field === 'quantity' ? localQuantity : localPrice;
if (localVal === null) return;
const numVal = parseToNum(localVal);
if (numVal !== item[field]) {
onUpdate(item.id, getUpdatePayload({ [field]: numVal }));
}
};
const handleContainerCreated = (newContainer: ProductContainer) => {
setIsModalOpen(false);
if (activeProduct) {
setAddedContainers(prev => ({ ...prev, [activeProduct.id]: [...(prev[activeProduct.id] || []), newContainer] }));
}
onUpdate(item.id, getUpdatePayload({ container_id: newContainer.id }));
};
const cardBorderColor = !item.product_id ? '#ffa39e' : item.is_matched ? '#b7eb8f' : '#d9d9d9';
const uiSum = parseToNum(localQuantity) * parseToNum(localPrice);
return (
<Card
size="small"
style={{
marginBottom: 8,
borderLeft: `4px solid ${cardBorderColor}`,
background: item.product_id ? '#fff' : '#fff1f0' // Легкий красный фон если не распознан
}}
bodyStyle={{ padding: 12 }}
>
<Flex vertical gap="small">
{/* Верхняя строка: Исходное название и статус */}
<Flex justify="space-between" align="start">
<div style={{ flex: 1 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.raw_name}
</Text>
{item.raw_amount > 0 && (
<Text type="secondary" style={{ fontSize: 11, display: 'block' }}>
(в чеке: {item.raw_amount} x {item.raw_price})
</Text>
)}
</div>
<div>
{isUpdating && <SyncOutlined spin style={{ color: '#1890ff' }} />}
{!item.product_id && <Tag color="error">Не найден</Tag>}
</div>
</Flex>
{/* Выбор товара */}
<CatalogSelect
catalog={catalog}
value={item.product_id || undefined}
onChange={handleProductChange}
/>
{/* Нижний блок: Фасовка, Кол-во, Цена, Сумма */}
<Flex gap={8} align="center">
{/* Если есть фасовки, показываем селект. Если нет - просто лейбл ед. изм */}
<div style={{ flex: 2, minWidth: 90 }}>
{containerOptions.length > 1 ? (
<Select
size="middle"
style={{ width: '100%' }}
placeholder="Ед. изм."
options={containerOptions}
value={item.container_id || null} // null для базовой
onChange={handleContainerChange}
disabled={!item.product_id}
<>
<Card
size="small"
style={{ marginBottom: 8, borderLeft: `4px solid ${cardBorderColor}`, background: item.product_id ? '#fff' : '#fff1f0' }}
bodyStyle={{ padding: 12 }}
>
<Flex vertical gap={10}>
<Flex justify="space-between" align="start">
<div style={{ flex: 1 }}>
<Text type="secondary" style={{ fontSize: 12, lineHeight: 1.2, display: 'block' }}>{item.raw_name}</Text>
{item.raw_amount > 0 && (
<Text type="secondary" style={{ fontSize: 10, display: 'block' }}>(чек: {item.raw_amount} x {item.raw_price})</Text>
)}
</div>
<div style={{ marginLeft: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
{isUpdating && <SyncOutlined spin style={{ color: '#1890ff' }} />}
{/* Warning Icon */}
{activeWarning && (
<WarningFilled
style={{ color: '#faad14', fontSize: 16, cursor: 'pointer' }}
onClick={showWarningModal}
/>
) : (
<div style={{ padding: '4px 11px', background: '#f5f5f5', borderRadius: 6, fontSize: 13, color: '#888', border: '1px solid #d9d9d9' }}>
{selectedProductObj?.measure_unit || 'ед.'}
</div>
)}
</div>
)}
{!item.product_id && <Tag color="error" style={{ margin: 0 }}>?</Tag>}
</div>
</Flex>
<InputNumber
style={{ flex: 1.5, minWidth: 60 }}
placeholder="Кол-во"
value={item.quantity}
min={0}
onBlur={(e) => handleBlur('quantity', parseFloat(e.target.value))}
// В Antd onBlur event target value is string
<CatalogSelect
value={item.product_id || undefined}
onChange={handleProductChange}
initialProduct={activeProduct}
/>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', minWidth: 60 }}>
<InputNumber
style={{ width: '100%' }}
placeholder="Цена"
value={item.price}
min={0}
onBlur={(e) => handleBlur('price', parseFloat(e.target.value))}
/>
<Text type="secondary" style={{ fontSize: 10 }}>Цена за ед.</Text>
{activeProduct && (
<Select
style={{ width: '100%' }}
placeholder="Выберите единицу измерения"
options={containerOptions}
value={item.container_id || 'BASE_UNIT'}
onChange={handleContainerChange}
dropdownRender={(menu) => (
<>
{menu}
<Divider style={{ margin: '4px 0' }} />
<Button type="text" block icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} style={{ textAlign: 'left' }}>
Добавить фасовку...
</Button>
</>
)}
/>
)}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: '#fafafa', margin: '0 -12px -12px -12px', padding: '8px 12px',
borderTop: '1px solid #f0f0f0', borderBottomLeftRadius: 8, borderBottomRightRadius: 8
}}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<InputNumber<string>
style={{ width: 60 }} controls={false} placeholder="Кол" stringMode decimalSeparator=","
value={localQuantity || ''} onChange={(val) => setLocalQuantity(val)} onBlur={() => handleBlur('quantity')}
/>
<Text type="secondary">x</Text>
<InputNumber<string>
style={{ width: 70 }} controls={false} placeholder="Цена" stringMode decimalSeparator=","
value={localPrice || ''} onChange={(val) => setLocalPrice(val)} onBlur={() => handleBlur('price')}
/>
</div>
<div style={{ textAlign: 'right' }}>
<Text strong style={{ fontSize: 16 }}>
{uiSum.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB', minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</Text>
</div>
</div>
</Flex>
</Card>
{/* Итоговая сумма (расчетная) */}
<div style={{ textAlign: 'right', marginTop: 4 }}>
<Text strong>
= {(item.quantity * item.price).toLocaleString('ru-RU', { style: 'currency', currency: 'RUB' })}
</Text>
</div>
</Flex>
</Card>
{activeProduct && (
<CreateContainerModal
visible={isModalOpen}
onCancel={() => setIsModalOpen(false)}
productId={activeProduct.id}
productBaseUnit={baseUom}
onSuccess={handleContainerCreated}
/>
)}
</>
);
};

View File

@@ -1,57 +1,86 @@
import React from 'react';
import { Layout, Menu, theme } from 'antd';
import { Layout, theme } from 'antd';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { BarChartOutlined, ScanOutlined, FileTextOutlined } from '@ant-design/icons';
import { ScanOutlined, FileTextOutlined, SettingOutlined } from '@ant-design/icons';
const { Header, Content, Footer } = Layout;
const { Content } = Layout;
export const AppLayout: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
// Получаем токены темы (чтобы подстроить AntD под Telegram можно позже настроить ConfigProvider)
const {
token: { colorBgContainer, borderRadiusLG },
token: { colorBgContainer, colorPrimary, colorTextSecondary },
} = theme.useToken();
// Определяем активный пункт меню
const selectedKey = location.pathname === '/' ? 'dashboard'
: location.pathname.startsWith('/ocr') ? 'ocr'
: location.pathname.startsWith('/invoices') ? 'invoices'
: 'dashboard';
const path = location.pathname;
let activeKey = 'invoices';
if (path.startsWith('/ocr')) activeKey = 'ocr';
else if (path.startsWith('/settings')) activeKey = 'settings';
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') },
{ key: 'invoices', icon: <FileTextOutlined style={{ fontSize: 20 }} />, label: 'Накладные', path: '/invoices' },
{ key: 'ocr', icon: <ScanOutlined style={{ fontSize: 20 }} />, label: 'Обучение', path: '/ocr' },
{ key: 'settings', icon: <SettingOutlined style={{ fontSize: 20 }} />, label: 'Настройки', path: '#' },
];
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' }}>
<Layout style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Верхнюю шапку (Header) удалили для экономии места */}
<Content style={{ padding: '0', flex: 1, overflowY: 'auto', marginBottom: 60 }}>
{/* Убрали лишние паддинги вокруг контента для мобилок */}
<div
style={{
background: colorBgContainer,
minHeight: 280,
padding: 24,
borderRadius: borderRadiusLG,
minHeight: '100%',
padding: '12px 12px 80px 12px', // Добавили отступ снизу, чтобы контент не перекрывался меню
borderRadius: 0, // На мобильных скругления углов всего экрана обычно не нужны
}}
>
<Outlet />
</div>
</Content>
<Footer style={{ textAlign: 'center', padding: '12px 0' }}>
RMSer ©{new Date().getFullYear()}
</Footer>
{/* Нижний Таб-бар */}
<div style={{
position: 'fixed',
bottom: 0,
width: '100%',
zIndex: 1000,
background: '#fff',
borderTop: '1px solid #f0f0f0',
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center',
padding: '8px 0',
height: 60,
boxShadow: '0 -2px 8px rgba(0,0,0,0.05)'
}}>
{menuItems.map(item => {
const isActive = activeKey === item.key;
return (
<div
key={item.key}
onClick={() => navigate(item.path)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '33%',
cursor: 'pointer',
color: isActive ? colorPrimary : colorTextSecondary
}}
>
{item.icon}
<span style={{ fontSize: 10, marginTop: 2, fontWeight: isActive ? 500 : 400 }}>
{item.label}
</span>
</div>
);
})}
</div>
</Layout>
);
};

View File

@@ -1,13 +1,13 @@
import React, { useState, useMemo } from 'react'; // Убрали useEffect
import React, { useState, useMemo } from 'react';
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';
import type { CatalogItem, UnmatchedItem, ProductSearchResult } from '../../services/types';
const { Text } = Typography;
interface Props {
catalog: CatalogItem[];
catalog: CatalogItem[]; // Оставляем для совместимости, но CatalogSelect его больше не использует
unmatched?: UnmatchedItem[];
onSave: (rawName: string, productId: string, quantity: number, containerId?: string) => void;
isLoading: boolean;
@@ -16,6 +16,9 @@ interface Props {
export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave, isLoading }) => {
const [rawName, setRawName] = useState('');
const [selectedProduct, setSelectedProduct] = useState<string | undefined>(undefined);
// Сохраняем полный объект товара, полученный из поиска, чтобы иметь доступ к containers
const [selectedProductData, setSelectedProductData] = useState<ProductSearchResult | undefined>(undefined);
const [quantity, setQuantity] = useState<number | null>(1);
const [selectedContainer, setSelectedContainer] = useState<string | null>(null);
@@ -26,23 +29,31 @@ export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave,
}));
}, [unmatched]);
const selectedCatalogItem = useMemo(() => {
if (!selectedProduct) return null;
return catalog.find(item => item.id === selectedProduct || item.ID === selectedProduct);
}, [selectedProduct, catalog]);
// Вычисляем активный товар: либо из результатов поиска, либо ищем в старом каталоге (если он передан)
const activeProduct = useMemo(() => {
if (selectedProductData) return selectedProductData;
if (selectedProduct && catalog.length > 0) {
// Приводим типы, так как CatalogItem расширяет ProductSearchResult
return catalog.find(item => item.id === selectedProduct) as unknown as ProductSearchResult;
}
return null;
}, [selectedProduct, selectedProductData, catalog]);
// Хендлер смены товара: сразу сбрасываем фасовку
const handleProductChange = (val: string) => {
// Хендлер смены товара: принимаем и ID, и объект
const handleProductChange = (val: string, productObj?: ProductSearchResult) => {
setSelectedProduct(val);
if (productObj) {
setSelectedProductData(productObj);
}
setSelectedContainer(null);
};
// Мемоизируем список контейнеров, чтобы он был стабильной зависимостью
const containers = useMemo(() => {
return selectedCatalogItem?.containers || selectedCatalogItem?.Containers || [];
}, [selectedCatalogItem]);
return activeProduct?.containers || [];
}, [activeProduct]);
const baseUom = selectedCatalogItem?.measure_unit || selectedCatalogItem?.MeasureUnit || 'ед.';
// Берем единицу измерения с учетом новой структуры (main_unit)
const baseUom = activeProduct?.main_unit?.name || activeProduct?.measure_unit || 'ед.';
const currentUomName = useMemo(() => {
if (selectedContainer) {
@@ -58,6 +69,7 @@ export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave,
setRawName('');
setSelectedProduct(undefined);
setSelectedProductData(undefined);
setQuantity(1);
setSelectedContainer(null);
}
@@ -85,9 +97,9 @@ export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave,
<div>
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>Товар в iiko:</div>
<CatalogSelect
catalog={catalog}
// Удален проп catalog={catalog}, так как компонент теперь ищет товары сам
value={selectedProduct}
onChange={handleProductChange} // Используем новый хендлер
onChange={handleProductChange}
disabled={isLoading}
/>
</div>
@@ -103,7 +115,7 @@ export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave,
{ value: null, label: `Базовая единица (${baseUom})` },
...containers.map(c => ({
value: c.id,
label: `${c.name} (=${c.count} ${baseUom})`
label: `${c.name} (=${Number(c.count)} ${baseUom})`
}))
]}
/>

View File

@@ -1,56 +1,92 @@
import React, { useMemo } from 'react';
import { Select } from 'antd';
import type { CatalogItem } from '../../services/types';
import React, { useState, useEffect, useRef } from 'react';
import { Select, Spin } from 'antd';
import { api } from '../../services/api';
import type { CatalogItem, ProductSearchResult } from '../../services/types';
interface Props {
catalog: CatalogItem[];
value?: string;
onChange?: (value: string) => void;
onChange?: (value: string, productObj?: ProductSearchResult) => void;
disabled?: boolean;
initialProduct?: CatalogItem | ProductSearchResult;
}
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
// Интерфейс для элемента выпадающего списка
interface SelectOption {
label: string;
value: string;
data: ProductSearchResult;
}
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]);
export const CatalogSelect: React.FC<Props> = ({ value, onChange, disabled, initialProduct }) => {
const [options, setOptions] = useState<SelectOption[]>([]);
const [fetching, setFetching] = useState(false);
const fetchRef = useRef<number | null>(null);
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();
useEffect(() => {
if (initialProduct && initialProduct.id === value) {
const name = initialProduct.name;
const code = initialProduct.code;
setOptions([{
label: code ? `${name} [${code}]` : name,
value: initialProduct.id,
data: initialProduct as ProductSearchResult
}]);
}
}, [initialProduct, value]);
return name.includes(search) || code.includes(search);
const fetchProducts = async (search: string) => {
if (!search) return;
setFetching(true);
setOptions([]);
try {
const results = await api.searchProducts(search);
const newOptions = results.map(item => ({
label: item.code ? `${item.name} [${item.code}]` : item.name,
value: item.id,
data: item
}));
setOptions(newOptions);
} catch (e) {
console.error(e);
} finally {
setFetching(false);
}
};
const handleSearch = (val: string) => {
if (fetchRef.current !== null) {
window.clearTimeout(fetchRef.current);
}
fetchRef.current = window.setTimeout(() => {
fetchProducts(val);
}, 500);
};
// Исправлено: добавлен | undefined для option
const handleChange = (val: string, option: SelectOption | SelectOption[] | undefined) => {
if (onChange) {
// В single mode option - это один объект или undefined
const opt = Array.isArray(option) ? option[0] : option;
onChange(val, opt?.data);
}
};
return (
<Select
showSearch
placeholder="Выберите товар из iiko"
optionFilterProp="children"
filterOption={filterOption}
placeholder="Начните вводить название товара..."
filterOption={false}
onSearch={handleSearch}
notFoundContent={fetching ? <Spin size="small" /> : null}
options={options}
value={value}
onChange={onChange}
onChange={handleChange}
disabled={disabled}
style={{ width: '100%' }}
listHeight={256}
allowClear
/>
);
};

View File

@@ -13,9 +13,9 @@ 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 raw = (item.raw_name || '').toLowerCase();
const prod = item.product;
const prodName = (prod?.name || '').toLowerCase();
const search = searchText.toLowerCase();
return raw.includes(search) || prodName.includes(search);
});
@@ -37,14 +37,14 @@ export const MatchList: React.FC<Props> = ({ matches }) => {
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;
// Унификация полей (только snake_case)
const rawName = item.raw_name || 'Без названия';
const product = item.product;
const productName = product?.name || 'Товар не найден';
const qty = item.quantity || 1;
// Логика отображения Единицы или Фасовки
const container = item.container || item.Container;
const container = item.container;
let displayUnit = '';
if (container) {
@@ -52,7 +52,7 @@ export const MatchList: React.FC<Props> = ({ matches }) => {
displayUnit = container.name;
} else {
// Иначе базовая ед.: "кг"
displayUnit = product?.measure_unit || product?.MeasureUnit || 'ед.';
displayUnit = product?.measure_unit || 'ед.';
}
return (

View File

@@ -0,0 +1,86 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { List, Typography, Tag, Spin, Empty } from 'antd';
import { useNavigate } from 'react-router-dom';
import { ArrowRightOutlined } from '@ant-design/icons';
import { api } from '../services/api';
const { Title, Text } = Typography;
export const DraftsList: React.FC = () => {
const navigate = useNavigate();
const { data: drafts, isLoading, isError } = useQuery({
queryKey: ['drafts'],
queryFn: api.getDrafts,
refetchOnWindowFocus: true
});
const getStatusTag = (status: string) => {
switch (status) {
case 'PROCESSING': return <Tag color="blue">Обработка</Tag>;
case 'READY_TO_VERIFY': return <Tag color="orange">Проверка</Tag>;
case 'COMPLETED': return <Tag color="green">Готово</Tag>;
case 'ERROR': return <Tag color="red">Ошибка</Tag>;
case 'CANCELED': return <Tag color="default" style={{ color: '#999' }}>Отменен</Tag>;
default: return <Tag>{status}</Tag>;
}
};
if (isLoading) {
return <div style={{ textAlign: 'center', padding: 40 }}><Spin size="large" /></div>;
}
if (isError) {
return <div style={{ padding: 20, textAlign: 'center' }}>Ошибка загрузки списка</div>;
}
return (
<div style={{ padding: '0 16px 20px' }}>
<Title level={4} style={{ marginTop: 16, marginBottom: 16 }}>Черновики накладных</Title>
{(!drafts || drafts.length === 0) ? (
<Empty description="Нет активных черновиков" />
) : (
<List
itemLayout="horizontal"
dataSource={drafts}
renderItem={(item) => (
<List.Item
style={{
background: '#fff',
padding: 12,
marginBottom: 8,
borderRadius: 8,
cursor: 'pointer',
opacity: item.status === 'CANCELED' ? 0.6 : 1 // Делаем отмененные бледными
}}
onClick={() => navigate(`/invoice/${item.id}`)}
>
<div style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<Text strong style={{ fontSize: 16, textDecoration: item.status === 'CANCELED' ? 'line-through' : 'none' }}>
{item.document_number || 'Без номера'}
</Text>
{getStatusTag(item.status)}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#888', fontSize: 13 }}>
<span>{new Date(item.date_incoming).toLocaleDateString()}</span>
<span>{item.items_count} поз.</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 6, alignItems: 'center' }}>
<Text strong>
{item.total_sum.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB' })}
</Text>
<ArrowRightOutlined style={{ color: '#1890ff' }} />
</div>
</div>
</List.Item>
)}
/>
)}
</div>
);
};

View File

@@ -3,16 +3,17 @@ import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Spin, Alert, Button, Form, Select, DatePicker, Input,
Typography, message, Row, Col, Affix
Typography, message, Row, Col, Affix, Modal, Tag
} from 'antd';
import { ArrowLeftOutlined, CheckOutlined } from '@ant-design/icons';
import { ArrowLeftOutlined, CheckOutlined, DeleteOutlined, ExclamationCircleFilled, RestOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '../services/api';
import { DraftItemRow } from '../components/invoices/DraftItemRow';
import type { UpdateDraftItemRequest, CommitDraftRequest } from '../services/types';
const { Title, Text } = Typography;
const { Text } = Typography;
const { TextArea } = Input;
const { confirm } = Modal;
export const InvoiceDraftPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -20,27 +21,26 @@ export const InvoiceDraftPage: React.FC = () => {
const queryClient = useQueryClient();
const [form] = Form.useForm();
// Локальное состояние для отслеживания какие строки сейчас обновляются
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
// 1. Загрузка справочников
// --- ЗАПРОСЫ ---
const storesQuery = useQuery({ queryKey: ['stores'], queryFn: api.getStores });
const suppliersQuery = useQuery({ queryKey: ['suppliers'], queryFn: api.getSuppliers });
const catalogQuery = useQuery({ queryKey: ['catalog'], queryFn: api.getCatalogItems, staleTime: 1000 * 60 * 10 });
// 2. Загрузка черновика
const recommendationsQuery = useQuery({ queryKey: ['recommendations'], queryFn: api.getRecommendations });
const draftQuery = useQuery({
queryKey: ['draft', id],
queryFn: () => api.getDraft(id!),
enabled: !!id,
refetchInterval: (query) => {
const status = query.state.data?.status;
// Продолжаем опрашивать, пока статус PROCESSING, чтобы подтянуть новые товары, если они долетают
return status === 'PROCESSING' ? 3000 : false;
},
});
// 3. Мутация обновления строки
const draft = draftQuery.data;
// ... (МУТАЦИИ оставляем без изменений) ...
const updateItemMutation = useMutation({
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
api.updateDraftItem(id!, vars.itemId, vars.payload),
@@ -62,7 +62,6 @@ export const InvoiceDraftPage: React.FC = () => {
}
});
// 4. Мутация коммита
const commitMutation = useMutation({
mutationFn: (payload: CommitDraftRequest) => api.commitDraft(id!, payload),
onSuccess: (data) => {
@@ -74,33 +73,35 @@ export const InvoiceDraftPage: React.FC = () => {
}
});
const draft = draftQuery.data;
const deleteDraftMutation = useMutation({
mutationFn: () => api.deleteDraft(id!),
onSuccess: () => {
if (draft?.status === 'CANCELED') {
message.info('Черновик удален окончательно');
navigate('/invoices');
} else {
message.warning('Черновик отменен');
queryClient.invalidateQueries({ queryKey: ['draft', id] });
}
},
onError: () => {
message.error('Ошибка при удалении');
}
});
// Инициализация формы.
// Убрали проверку status !== 'PROCESSING', чтобы форма заполнялась сразу, как пришли данные.
// --- ЭФФЕКТЫ ---
useEffect(() => {
if (draft) {
// Проверяем, не менял ли пользователь уже поля, чтобы не перезатирать их при поллинге
const currentValues = form.getFieldsValue();
if (!currentValues.store_id && draft.store_id) {
form.setFieldValue('store_id', draft.store_id);
}
if (!currentValues.supplier_id && draft.supplier_id) {
form.setFieldValue('supplier_id', draft.supplier_id);
}
if (!currentValues.comment && draft.comment) {
form.setFieldValue('comment', draft.comment);
}
// Дату ставим, если её нет в форме
if (!currentValues.date_incoming) {
form.setFieldValue('date_incoming', draft.date_incoming ? dayjs(draft.date_incoming) : dayjs());
}
if (!currentValues.store_id && draft.store_id) form.setFieldValue('store_id', draft.store_id);
if (!currentValues.supplier_id && draft.supplier_id) form.setFieldValue('supplier_id', draft.supplier_id);
if (!currentValues.comment && draft.comment) form.setFieldValue('comment', draft.comment);
if (!currentValues.date_incoming) form.setFieldValue('date_incoming', draft.date_incoming ? dayjs(draft.date_incoming) : dayjs());
}
}, [draft, form]);
// Вычисляемые данные для UI
// --- ХЕЛПЕРЫ ---
const totalSum = useMemo(() => {
// Добавил Number(), так как API возвращает строки ("250"), а нам нужны числа
return draft?.items.reduce((acc, item) => acc + (Number(item.quantity) * Number(item.price)), 0) || 0;
}, [draft?.items]);
@@ -116,10 +117,9 @@ export const InvoiceDraftPage: React.FC = () => {
try {
const values = await form.validateFields();
if (invalidItemsCount > 0) {
message.warning(`Осталось ${invalidItemsCount} нераспознанных товаров! Удалите их или сопоставьте.`);
message.warning(`Осталось ${invalidItemsCount} нераспознанных товаров!`);
return;
}
commitMutation.mutate({
date_incoming: values.date_incoming.format('YYYY-MM-DD'),
store_id: values.store_id,
@@ -127,115 +127,147 @@ export const InvoiceDraftPage: React.FC = () => {
comment: values.comment || '',
});
} catch {
message.error('Заполните обязательные поля в шапке (Склад, Поставщик)');
message.error('Заполните обязательные поля');
}
};
// --- Рендер ---
const isCanceled = draft?.status === 'CANCELED';
// Показываем спиннер ТОЛЬКО если данных нет вообще, или статус PROCESSING и список пуст.
// Если статус PROCESSING, но items уже пришли — показываем интерфейс.
const handleDelete = () => {
confirm({
title: isCanceled ? 'Удалить окончательно?' : 'Отменить черновик?',
icon: <ExclamationCircleFilled style={{ color: 'red' }} />,
content: isCanceled
? 'Черновик пропадет из списка навсегда.'
: 'Черновик получит статус "Отменен", но останется в списке.',
okText: isCanceled ? 'Удалить навсегда' : 'Отменить',
okType: 'danger',
cancelText: 'Назад',
onOk() {
deleteDraftMutation.mutate();
},
});
};
// --- RENDER ---
const showSpinner = draftQuery.isLoading || (draft?.status === 'PROCESSING' && (!draft?.items || draft.items.length === 0));
if (showSpinner) {
return (
<div style={{ textAlign: 'center', padding: 50 }}>
<Spin size="large" tip="Обработка чека..." />
<div style={{ marginTop: 16, color: '#888' }}>ИИ читает ваш чек, подождите...</div>
</div>
);
return <div style={{ textAlign: 'center', padding: 50 }}><Spin size="large" /></div>;
}
if (draftQuery.isError || !draft) {
return <Alert type="error" message="Ошибка загрузки черновика" description="Попробуйте обновить страницу" />;
return <Alert type="error" message="Ошибка загрузки черновика" />;
}
return (
<div style={{ paddingBottom: 80 }}>
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 12 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/')} />
<Title level={4} style={{ margin: 0 }}>
Черновик {draft.document_number ? `${draft.document_number}` : ''}
{draft.status === 'PROCESSING' && <Spin size="small" style={{ marginLeft: 8 }} />}
</Title>
<div style={{ paddingBottom: 60 }}>
{/* 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}` : 'Черновик'}
</span>
{draft.status === 'PROCESSING' && <Spin size="small" />}
{isCanceled && <Tag color="red" style={{ margin: 0 }}>ОТМЕНЕН</Tag>}
</div>
</div>
<Button
danger={isCanceled}
type={isCanceled ? 'primary' : 'default'}
icon={isCanceled ? <DeleteOutlined /> : <RestOutlined />}
onClick={handleDelete}
loading={deleteDraftMutation.isPending}
size="small"
>
{isCanceled ? 'Удалить' : 'Отмена'}
</Button>
</div>
<div style={{ background: '#fff', padding: 16, borderRadius: 8, marginBottom: 16 }}>
{/* Form: Compact margins */}
<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={12}>
<Row gutter={10}>
<Col span={12}>
<Form.Item label="Дата" name="date_incoming" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" />
<Form.Item label="Дата" name="date_incoming" rules={[{ required: true }]} style={{ marginBottom: 8 }}>
<DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" size="middle" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="Склад" name="store_id" rules={[{ required: true, message: 'Выберите склад' }]}>
<Form.Item label="Склад" name="store_id" rules={[{ required: true }]} style={{ marginBottom: 8 }}>
<Select
placeholder="Куда?"
loading={storesQuery.isLoading}
options={storesQuery.data?.map(s => ({ label: s.name, value: s.id }))}
size="middle"
/>
</Form.Item>
</Col>
</Row>
<Form.Item label="Поставщик" name="supplier_id" rules={[{ required: true, message: 'Выберите поставщика' }]}>
<Form.Item label="Поставщик" name="supplier_id" rules={[{ required: true }]} style={{ marginBottom: 8 }}>
<Select
placeholder="От кого?"
loading={suppliersQuery.isLoading}
options={suppliersQuery.data?.map(s => ({ label: s.name, value: s.id }))}
size="middle"
/>
</Form.Item>
<Form.Item label="Комментарий" name="comment">
<TextArea rows={1} placeholder="Прим: Довоз за пятницу" />
<Form.Item label="Комментарий" name="comment" style={{ marginBottom: 0 }}>
<TextArea rows={1} placeholder="Комментарий..." style={{ fontSize: 13 }} />
</Form.Item>
</Form>
</div>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={5} style={{ margin: 0 }}>Позиции ({draft.items.length})</Title>
{invalidItemsCount > 0 && <Text type="danger">{invalidItemsCount} нераспознано</Text>}
{/* Items Header */}
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 4px' }}>
<Text strong>Позиции ({draft.items.length})</Text>
{invalidItemsCount > 0 && <Text type="danger" style={{ fontSize: 12 }}>{invalidItemsCount} нераспознано</Text>}
</div>
{/* Items List */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{draft.items.map(item => (
<DraftItemRow
key={item.id}
item={item}
catalog={catalogQuery.data || []}
onUpdate={handleItemUpdate}
isUpdating={updatingItems.has(item.id)}
recommendations={recommendationsQuery.data || []}
/>
))}
</div>
<Affix offsetBottom={0}>
{/* Footer Actions */}
<Affix offsetBottom={60} /* Высота нижнего меню */>
<div style={{
background: '#fff',
padding: '12px 16px',
padding: '8px 16px',
borderTop: '1px solid #eee',
boxShadow: '0 -2px 10px rgba(0,0,0,0.05)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
borderRadius: '8px 8px 0 0' // Скругление сверху
}}>
<div>
<div style={{ fontSize: 12, color: '#888' }}>Итого:</div>
<div style={{ fontSize: 18, fontWeight: 'bold', color: '#1890ff' }}>
{totalSum.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB' })}
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontSize: 11, color: '#888', lineHeight: 1 }}>Итого:</span>
<span style={{ fontSize: 18, fontWeight: 'bold', color: '#1890ff', lineHeight: 1.2 }}>
{totalSum.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB', maximumFractionDigits: 0 })}
</span>
</div>
<Button
type="primary"
size="large"
icon={<CheckOutlined />}
onClick={handleCommit}
loading={commitMutation.isPending}
disabled={invalidItemsCount > 0}
disabled={invalidItemsCount > 0 || isCanceled}
style={{ height: 40, padding: '0 24px' }}
>
Отправить в iiko
{isCanceled ? 'Восстановить' : 'Отправить'}
</Button>
</div>
</Affix>

View File

@@ -2,7 +2,7 @@ import axios from 'axios';
import type {
CatalogItem,
CreateInvoiceRequest,
MatchRequest, // Используем новый тип
MatchRequest,
HealthResponse,
InvoiceResponse,
ProductMatch,
@@ -12,7 +12,11 @@ import type {
Supplier,
DraftInvoice,
UpdateDraftItemRequest,
CommitDraftRequest
CommitDraftRequest,
// Новые типы
ProductSearchResult,
AddContainerRequest,
AddContainerResponse
} from './types';
// Базовый URL
@@ -33,7 +37,7 @@ apiClient.interceptors.response.use(
}
);
// Мок поставщиков (так как эндпоинта пока нет)
// Мок поставщиков
const MOCK_SUPPLIERS: Supplier[] = [
{ id: '00000000-0000-0000-0000-000000000001', name: 'ООО "Рога и Копыта"' },
{ id: '00000000-0000-0000-0000-000000000002', name: 'ИП Иванов (Овощи)' },
@@ -41,6 +45,17 @@ const MOCK_SUPPLIERS: Supplier[] = [
{ 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');
@@ -52,11 +67,28 @@ 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 }
});
return data;
},
// Создание фасовки ---
createContainer: async (payload: AddContainerRequest): Promise<AddContainerResponse> => {
// Внимание: URL эндпоинта взят из вашего ТЗ (/drafts/container)
const { data } = await apiClient.post<AddContainerResponse>('/drafts/container', payload);
return data;
},
getMatches: async (): Promise<ProductMatch[]> => {
const { data } = await apiClient.get<ProductMatch[]>('/ocr/matches');
return data;
@@ -67,7 +99,6 @@ export const api = {
return data;
},
// Обновили тип аргумента payload
createMatch: async (payload: MatchRequest): Promise<{ status: string }> => {
const { data } = await apiClient.post<{ status: string }>('/ocr/match', payload);
return data;
@@ -77,39 +108,41 @@ export const api = {
const { data } = await apiClient.post<InvoiceResponse>('/invoices/send', payload);
return data;
},
// Получить список складов
getStores: async (): Promise<Store[]> => {
const { data } = await apiClient.get<Store[]>('/dictionaries/stores');
return data;
},
// Получить список поставщиков (Mock)
getSuppliers: async (): Promise<Supplier[]> => {
// Имитация асинхронности
return new Promise((resolve) => {
setTimeout(() => resolve(MOCK_SUPPLIERS), 300);
});
},
// Получить черновик
getDraft: async (id: string): Promise<DraftInvoice> => {
const { data } = await apiClient.get<DraftInvoice>(`/drafts/${id}`);
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> => {
// Бэкенд возвращает обновленный черновик целиком (обычно) или обновленный item.
// Предположим, что возвращается обновленный Item или просто 200 OK.
// Но для React Query удобно возвращать данные.
// Если бэк возвращает только item, типизацию нужно уточнить. Пока ждем DraftInvoice или any.
const { data } = await apiClient.patch<DraftInvoice>(`/drafts/${draftId}/items/${itemId}`, payload);
return data;
},
// Зафиксировать черновик
commitDraft: async (draftId: string, payload: CommitDraftRequest): Promise<{ document_number: string }> => {
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}`);
},
};

View File

@@ -10,15 +10,36 @@ export interface ProductContainer {
count: number; // 0.180
}
export interface CatalogItem {
// Основные поля (snake_case)
// Запрос на создание фасовки
export interface AddContainerRequest {
product_id: UUID;
name: string; // "Бутылка 0.75"
count: number; // 0.75
}
// Ответ на создание фасовки
export interface AddContainerResponse {
status: string;
container_id: UUID;
}
// Результат поиска товара
export interface ProductSearchResult {
id: UUID;
name: string;
code: string;
measure_unit: string; // "кг", "л"
containers: ProductContainer[]; // Массив фасовок
num?: string;
// Обновляем структуру единицы измерения
main_unit?: MainUnit;
measure_unit?: string; // Оставим для совместимости, но брать будем из main_unit.name
// Fallback (на всякий случай)
containers: ProductContainer[];
}
// Совместимость с CatalogItem (чтобы не ломать старый код, если он где-то используется)
export interface CatalogItem extends ProductSearchResult {
// Fallback поля
ID?: UUID;
Name?: string;
Code?: string;
@@ -32,11 +53,10 @@ export interface MatchRequest {
raw_name: string;
product_id: UUID;
quantity: number;
container_id?: UUID; // Новое поле
container_id?: UUID;
}
export interface ProductMatch {
// snake_case (v2.0)
raw_name: string;
product_id: UUID;
product?: CatalogItem;
@@ -44,14 +64,6 @@ export interface ProductMatch {
container_id?: UUID;
container?: ProductContainer;
updated_at: string;
// Fallback
RawName?: string;
ProductID?: UUID;
Product?: CatalogItem;
Quantity?: number;
ContainerId?: UUID;
Container?: ProductContainer;
}
// --- Нераспознанное ---
@@ -62,7 +74,7 @@ export interface UnmatchedItem {
last_seen: string;
}
// --- Остальные типы (без изменений) ---
// --- Остальные типы ---
export interface Recommendation {
ID: UUID;
@@ -111,7 +123,7 @@ export interface Supplier {
// --- Черновик Накладной (Draft) ---
export type DraftStatus = 'PROCESSING' | 'READY_TO_VERIFY' | 'COMPLETED' | 'ERROR';
export type DraftStatus = 'PROCESSING' | 'READY_TO_VERIFY' | 'COMPLETED' | 'ERROR' | 'CANCELED';
export interface DraftItem {
id: UUID;
@@ -160,4 +172,9 @@ export interface CommitDraftRequest {
store_id: UUID;
supplier_id: UUID;
comment: string;
}
export interface MainUnit {
id: UUID;
name: string; // "кг"
code: string;
}