mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
Полноценно редактируются черновики
Добавляются фасовки как в черновике, так и в обучении Исправил внешний вид
This commit is contained in:
@@ -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>
|
||||
|
||||
84
rmser-view/src/components/invoices/CreateContainerModal.tsx
Normal file
84
rmser-view/src/components/invoices/CreateContainerModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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})`
|
||||
}))
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
|
||||
86
rmser-view/src/pages/DraftsList.tsx
Normal file
86
rmser-view/src/pages/DraftsList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user