mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-05 03:12:34 -06:00
редактирование и удаление сопоставлений
список накладных с позициями
This commit is contained in:
@@ -5,6 +5,7 @@ import { Providers } from "./components/layout/Providers";
|
||||
import { AppLayout } from "./components/layout/AppLayout";
|
||||
import { OcrLearning } from "./pages/OcrLearning";
|
||||
import { InvoiceDraftPage } from "./pages/InvoiceDraftPage";
|
||||
import { InvoiceViewPage } from "./pages/InvoiceViewPage";
|
||||
import { DraftsList } from "./pages/DraftsList";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { UNAUTHORIZED_EVENT } from "./services/api";
|
||||
@@ -87,7 +88,8 @@ function App() {
|
||||
<Route index element={<Navigate to="/invoices" replace />} />
|
||||
<Route path="ocr" element={<OcrLearning />} />
|
||||
<Route path="invoices" element={<DraftsList />} />
|
||||
<Route path="invoice/:id" element={<InvoiceDraftPage />} />
|
||||
<Route path="invoice/draft/:id" element={<InvoiceDraftPage />} />
|
||||
<Route path="invoice/view/:id" element={<InvoiceViewPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
|
||||
@@ -1,46 +1,117 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card, Button, Flex, AutoComplete, InputNumber, Typography, Select, Divider } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { CatalogSelect } from './CatalogSelect';
|
||||
import { CreateContainerModal } from '../invoices/CreateContainerModal'; // Импортируем модалку
|
||||
import type { CatalogItem, UnmatchedItem, ProductSearchResult, ProductContainer } from '../../services/types';
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Flex,
|
||||
AutoComplete,
|
||||
Input,
|
||||
InputNumber,
|
||||
Typography,
|
||||
Select,
|
||||
Divider,
|
||||
Popconfirm,
|
||||
} from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
CloseOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { CatalogSelect } from "./CatalogSelect";
|
||||
import { CreateContainerModal } from "../invoices/CreateContainerModal";
|
||||
import type {
|
||||
CatalogItem,
|
||||
UnmatchedItem,
|
||||
ProductSearchResult,
|
||||
ProductContainer,
|
||||
ProductMatch,
|
||||
} from "../../services/types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
catalog: CatalogItem[];
|
||||
catalog: CatalogItem[];
|
||||
unmatched?: UnmatchedItem[];
|
||||
onSave: (rawName: string, productId: string, quantity: number, containerId?: string) => void;
|
||||
onSave: (
|
||||
rawName: string,
|
||||
productId: string,
|
||||
quantity: number,
|
||||
containerId?: string
|
||||
) => void;
|
||||
onDeleteUnmatched?: (rawName: string) => void;
|
||||
isLoading: boolean;
|
||||
initialValues?: ProductMatch; // Для редактирования
|
||||
onCancelEdit?: () => void; // Для сброса режима редактирования
|
||||
}
|
||||
|
||||
export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave, isLoading }) => {
|
||||
const [rawName, setRawName] = useState('');
|
||||
|
||||
// Состояния товара
|
||||
const [selectedProduct, setSelectedProduct] = useState<string | undefined>(undefined);
|
||||
const [selectedProductData, setSelectedProductData] = useState<ProductSearchResult | undefined>(undefined);
|
||||
|
||||
export const AddMatchForm: React.FC<Props> = ({
|
||||
catalog,
|
||||
unmatched = [],
|
||||
onSave,
|
||||
onDeleteUnmatched,
|
||||
isLoading,
|
||||
initialValues,
|
||||
onCancelEdit,
|
||||
}) => {
|
||||
// --- Состояния ---
|
||||
const [rawName, setRawName] = useState("");
|
||||
const [selectedProduct, setSelectedProduct] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
// Храним полный объект товара, чтобы достать из него фасовки и имя для отображения
|
||||
const [selectedProductData, setSelectedProductData] = useState<
|
||||
ProductSearchResult | undefined
|
||||
>(undefined);
|
||||
const [quantity, setQuantity] = useState<number | null>(1);
|
||||
const [selectedContainer, setSelectedContainer] = useState<string | null>(null);
|
||||
const [selectedContainer, setSelectedContainer] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Состояние модалки создания фасовки
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// --- Эффект для инициализации полей при редактировании ---
|
||||
useEffect(() => {
|
||||
if (initialValues) {
|
||||
// eslint-disable-next-line
|
||||
setRawName(initialValues.raw_name || "");
|
||||
|
||||
const prodId = initialValues.product?.id;
|
||||
setSelectedProduct(prodId);
|
||||
|
||||
// Важно: восстанавливаем объект продукта из initialValues
|
||||
// Приводим тип, так как DTO могут немного отличаться, но нам нужны containers и name
|
||||
const prodData = initialValues.product as unknown as ProductSearchResult;
|
||||
setSelectedProductData(prodData);
|
||||
|
||||
setQuantity(Number(initialValues.quantity) || 1);
|
||||
setSelectedContainer(initialValues.container?.id || null);
|
||||
} else {
|
||||
// РЕЖИМ СОЗДАНИЯ (Сброс)
|
||||
setRawName("");
|
||||
setSelectedProduct(undefined);
|
||||
setSelectedProductData(undefined);
|
||||
setQuantity(1);
|
||||
setSelectedContainer(null);
|
||||
}
|
||||
}, [initialValues]);
|
||||
|
||||
// --- Вычисляемые значения ---
|
||||
|
||||
const unmatchedOptions = useMemo(() => {
|
||||
return unmatched.map(item => ({
|
||||
return unmatched.map((item) => ({
|
||||
value: item.raw_name,
|
||||
label: item.count ? `${item.raw_name} (${item.count} шт)` : item.raw_name
|
||||
label: item.count ? `${item.raw_name} (${item.count} шт)` : item.raw_name,
|
||||
}));
|
||||
}, [unmatched]);
|
||||
|
||||
// Активный продукт (из поиска или из старого каталога)
|
||||
// Активный продукт: либо то, что выбрали в поиске, либо то, что пришло из редактирования
|
||||
const activeProduct = useMemo(() => {
|
||||
if (selectedProductData) return selectedProductData;
|
||||
// Фоллбэк: пытаемся найти в общем каталоге (если он загружен полностью, что редко)
|
||||
if (selectedProduct && catalog.length > 0) {
|
||||
return catalog.find(item => item.id === selectedProduct) as unknown as ProductSearchResult;
|
||||
return catalog.find(
|
||||
(item) => item.id === selectedProduct
|
||||
) as unknown as ProductSearchResult;
|
||||
}
|
||||
return undefined;
|
||||
}, [selectedProduct, selectedProductData, catalog]);
|
||||
@@ -49,121 +120,203 @@ export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave,
|
||||
const containers = useMemo(() => {
|
||||
return activeProduct?.containers || [];
|
||||
}, [activeProduct]);
|
||||
|
||||
|
||||
// Базовая единица
|
||||
const baseUom = activeProduct?.main_unit?.name || activeProduct?.measure_unit || 'ед.';
|
||||
|
||||
// Текстовое отображение текущей единицы
|
||||
const baseUom =
|
||||
activeProduct?.main_unit?.name || activeProduct?.measure_unit || "ед.";
|
||||
|
||||
// Текстовое отображение текущей единицы (для инпута количества)
|
||||
const currentUomName = useMemo(() => {
|
||||
if (selectedContainer) {
|
||||
const cont = containers.find(c => c.id === selectedContainer);
|
||||
const cont = containers.find((c) => c.id === selectedContainer);
|
||||
return cont ? cont.name : baseUom;
|
||||
}
|
||||
return baseUom;
|
||||
}, [selectedContainer, containers, baseUom]);
|
||||
|
||||
const isButtonDisabled = !rawName.trim() || !selectedProduct || !quantity || quantity <= 0 || isLoading;
|
||||
const isButtonDisabled =
|
||||
!rawName.trim() ||
|
||||
!selectedProduct ||
|
||||
quantity === null ||
|
||||
quantity <= 0 ||
|
||||
isLoading;
|
||||
|
||||
// --- Хендлеры ---
|
||||
|
||||
const handleProductChange = (val: string, productObj?: ProductSearchResult) => {
|
||||
const handleProductChange = (
|
||||
val: string,
|
||||
productObj?: ProductSearchResult
|
||||
) => {
|
||||
setSelectedProduct(val);
|
||||
if (productObj) {
|
||||
setSelectedProductData(productObj);
|
||||
}
|
||||
// При смене товара сбрасываем фасовку
|
||||
setSelectedContainer(null);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (rawName.trim() && selectedProduct && quantity && quantity > 0) {
|
||||
onSave(rawName, selectedProduct, quantity, selectedContainer || undefined);
|
||||
|
||||
// Сброс формы
|
||||
setRawName('');
|
||||
setSelectedProduct(undefined);
|
||||
setSelectedProductData(undefined);
|
||||
setQuantity(1);
|
||||
setSelectedContainer(null);
|
||||
let quantityValue = quantity;
|
||||
|
||||
// Защита от null/строк
|
||||
if (quantityValue === null || quantityValue === undefined) {
|
||||
quantityValue = 1;
|
||||
} else if (typeof quantityValue === "string") {
|
||||
quantityValue = parseFloat(quantityValue);
|
||||
}
|
||||
if (isNaN(quantityValue) || quantityValue <= 0) {
|
||||
quantityValue = 1;
|
||||
}
|
||||
|
||||
if (rawName.trim() && selectedProduct) {
|
||||
onSave(
|
||||
rawName,
|
||||
selectedProduct,
|
||||
quantityValue,
|
||||
selectedContainer || undefined
|
||||
);
|
||||
|
||||
// Если это не редактирование, очищаем форму
|
||||
if (!initialValues) {
|
||||
setRawName("");
|
||||
setSelectedProduct(undefined);
|
||||
setSelectedProductData(undefined);
|
||||
setQuantity(1);
|
||||
setSelectedContainer(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Обработка создания новой фасовки
|
||||
const handleContainerCreated = (newContainer: ProductContainer) => {
|
||||
setIsModalOpen(false);
|
||||
|
||||
// 1. Обновляем локальные данные продукта, добавляя новую фасовку в список
|
||||
// Добавляем созданную фасовку в локальный стейт продукта
|
||||
if (selectedProductData) {
|
||||
setSelectedProductData({
|
||||
...selectedProductData,
|
||||
containers: [...(selectedProductData.containers || []), newContainer]
|
||||
containers: [...(selectedProductData.containers || []), newContainer],
|
||||
});
|
||||
} else if (activeProduct) {
|
||||
// Если продукт был взят из общего catalog, создаем локальную копию
|
||||
setSelectedProductData({
|
||||
...activeProduct,
|
||||
containers: [...(activeProduct.containers || []), newContainer]
|
||||
});
|
||||
setSelectedProductData({
|
||||
...activeProduct,
|
||||
containers: [...(activeProduct.containers || []), newContainer],
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Сразу выбираем созданную фасовку
|
||||
// Выбираем новую фасовку
|
||||
setSelectedContainer(newContainer.id);
|
||||
};
|
||||
|
||||
const handleDeleteUnmatched = () => {
|
||||
if (onDeleteUnmatched && rawName.trim()) {
|
||||
onDeleteUnmatched(rawName);
|
||||
setRawName("");
|
||||
}
|
||||
};
|
||||
|
||||
// Кнопка "Сбросить" вызывает внешний обработчик отмены редактирования
|
||||
const handleCancel = () => {
|
||||
if (onCancelEdit) {
|
||||
onCancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="Добавить новую связь" size="small" style={{ marginBottom: 16 }}>
|
||||
<Card
|
||||
title={
|
||||
initialValues ? "✏️ Редактирование связи" : "➕ Добавить новую связь"
|
||||
}
|
||||
size="small"
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
borderColor: initialValues ? "#1890ff" : undefined, // Подсветка при редактировании
|
||||
background: initialValues ? "#f0f5ff" : undefined,
|
||||
}}
|
||||
>
|
||||
<Flex vertical gap="middle">
|
||||
{/* Поле текста из чека */}
|
||||
{/* Поле: Текст из чека */}
|
||||
<div>
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>Текст из чека:</div>
|
||||
<AutoComplete
|
||||
placeholder="Например: Масло слив. коробка"
|
||||
options={unmatchedOptions}
|
||||
value={rawName}
|
||||
onChange={setRawName}
|
||||
filterOption={(inputValue, option) =>
|
||||
!inputValue || (option?.value as string).toLowerCase().includes(inputValue.toLowerCase())
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}>
|
||||
Текст из чека (Raw Name):
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<AutoComplete
|
||||
options={unmatchedOptions}
|
||||
value={rawName}
|
||||
onChange={setRawName}
|
||||
filterOption={(inputValue, option) =>
|
||||
!inputValue ||
|
||||
(option?.value as string)
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase())
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="Например: Масло слив. коробка"
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
/>
|
||||
</AutoComplete>
|
||||
{onDeleteUnmatched && !initialValues && (
|
||||
<Popconfirm
|
||||
title="Удалить строку?"
|
||||
description="Удалить из списка нераспознанных?"
|
||||
onConfirm={handleDeleteUnmatched}
|
||||
okText="Да"
|
||||
cancelText="Нет"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={!rawName.trim()}
|
||||
title="Удалить мусорную строку"
|
||||
/>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Выбор товара (Поиск) */}
|
||||
{/* Поле: Товар */}
|
||||
<div>
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>Товар в iiko:</div>
|
||||
<CatalogSelect
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}>
|
||||
Товар в iiko:
|
||||
</div>
|
||||
<CatalogSelect
|
||||
value={selectedProduct}
|
||||
onChange={handleProductChange}
|
||||
disabled={isLoading}
|
||||
initialProduct={activeProduct} // Передаем полный объект для правильного отображения!
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Выбор фасовки (появляется только если есть активный товар) */}
|
||||
{/* Поле: Фасовка */}
|
||||
{activeProduct && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>Единица измерения / Фасовка:</div>
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}>
|
||||
Единица измерения / Фасовка:
|
||||
</div>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
style={{ width: "100%" }}
|
||||
value={selectedContainer}
|
||||
onChange={setSelectedContainer}
|
||||
placeholder="Выберите ед. измерения"
|
||||
options={[
|
||||
{ value: null, label: `Базовая единица (${baseUom})` },
|
||||
...containers.map(c => ({
|
||||
...containers.map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.name} (=${Number(c.count)} ${baseUom})`
|
||||
}))
|
||||
label: `${c.name} (=${Number(c.count)} ${baseUom})`,
|
||||
})),
|
||||
]}
|
||||
// Рендерим кнопку добавления внизу выпадающего списка
|
||||
dropdownRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
<Divider style={{ margin: '4px 0' }} />
|
||||
<Button
|
||||
type="text"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
<Divider style={{ margin: "4px 0" }} />
|
||||
<Button
|
||||
type="text"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
style={{ textAlign: 'left' }}
|
||||
style={{ textAlign: "left" }}
|
||||
>
|
||||
Добавить фасовку
|
||||
</Button>
|
||||
@@ -173,17 +326,17 @@ export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Количество */}
|
||||
{/* Поле: Количество */}
|
||||
<div>
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>
|
||||
Количество (в выбранных единицах):
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}>
|
||||
Коэффициент (сколько товара в одной позиции чека):
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<InputNumber
|
||||
min={0.001}
|
||||
step={selectedContainer ? 1 : 0.1}
|
||||
value={quantity}
|
||||
onChange={setQuantity}
|
||||
onChange={(val) => setQuantity(Number(val))}
|
||||
style={{ flex: 1 }}
|
||||
placeholder="1"
|
||||
/>
|
||||
@@ -191,20 +344,32 @@ export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопка сохранения */}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleSubmit}
|
||||
loading={isLoading}
|
||||
disabled={isButtonDisabled}
|
||||
block
|
||||
>
|
||||
Сохранить связь
|
||||
</Button>
|
||||
{/* Кнопки действий */}
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={initialValues ? <EditOutlined /> : <PlusOutlined />}
|
||||
onClick={handleSubmit}
|
||||
loading={isLoading}
|
||||
disabled={isButtonDisabled}
|
||||
block
|
||||
>
|
||||
{initialValues ? "Сохранить изменения" : "Добавить связь"}
|
||||
</Button>
|
||||
|
||||
{initialValues && (
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
icon={<CloseOutlined />}
|
||||
title="Отменить редактирование"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
{/* Модальное окно создания фасовки */}
|
||||
{/* Модалка создания фасовки */}
|
||||
{activeProduct && (
|
||||
<CreateContainerModal
|
||||
visible={isModalOpen}
|
||||
@@ -216,4 +381,4 @@ export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave,
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { api } from '../../services/api';
|
||||
import type { CatalogItem, ProductSearchResult } 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 {
|
||||
value?: string;
|
||||
@@ -17,35 +17,44 @@ interface SelectOption {
|
||||
data: ProductSearchResult;
|
||||
}
|
||||
|
||||
export const CatalogSelect: React.FC<Props> = ({ value, onChange, disabled, initialProduct }) => {
|
||||
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);
|
||||
|
||||
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
|
||||
}]);
|
||||
const name = initialProduct.name;
|
||||
const code = initialProduct.code;
|
||||
setOptions([
|
||||
{
|
||||
label: code ? `${name} [${code}]` : name,
|
||||
value: initialProduct.id,
|
||||
data: initialProduct as ProductSearchResult,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}, [initialProduct, value]);
|
||||
|
||||
const fetchProducts = async (search: string) => {
|
||||
if (!search) return;
|
||||
if (!search) {
|
||||
setOptions([]);
|
||||
return;
|
||||
}
|
||||
setFetching(true);
|
||||
setOptions([]);
|
||||
|
||||
// Не сбрасываем options сразу, чтобы не моргало
|
||||
try {
|
||||
const results = await api.searchProducts(search);
|
||||
const newOptions = results.map(item => ({
|
||||
const newOptions = results.map((item) => ({
|
||||
label: item.code ? `${item.name} [${item.code}]` : item.name,
|
||||
value: item.id,
|
||||
data: item
|
||||
data: item,
|
||||
}));
|
||||
setOptions(newOptions);
|
||||
} catch (e) {
|
||||
@@ -59,15 +68,20 @@ export const CatalogSelect: React.FC<Props> = ({ value, onChange, disabled, init
|
||||
if (fetchRef.current !== null) {
|
||||
window.clearTimeout(fetchRef.current);
|
||||
}
|
||||
// Запускаем поиск только если введено хотя бы 2 символа
|
||||
if (val.length < 2) {
|
||||
return;
|
||||
}
|
||||
fetchRef.current = window.setTimeout(() => {
|
||||
fetchProducts(val);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Исправлено: добавлен | undefined для option
|
||||
const handleChange = (val: string, option: SelectOption | SelectOption[] | undefined) => {
|
||||
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);
|
||||
}
|
||||
@@ -84,9 +98,15 @@ export const CatalogSelect: React.FC<Props> = ({ value, onChange, disabled, init
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
style={{ width: '100%' }}
|
||||
style={{ width: "100%" }}
|
||||
listHeight={256}
|
||||
allowClear
|
||||
// При очистке сбрасываем опции, чтобы при следующем клике не вылезал старый товар
|
||||
onClear={() => setOptions([])}
|
||||
// При клике (фокусе), если поле пустое - можно показать дефолтные опции или оставить пустым
|
||||
onFocus={() => {
|
||||
if (!value) setOptions([]);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
import React from 'react';
|
||||
import { List, Typography, Tag, Input, Empty } from 'antd';
|
||||
import { ArrowRightOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import type { ProductMatch } from '../../services/types';
|
||||
import React from "react";
|
||||
import { List, Typography, Tag, Input, Empty, Button, Popconfirm } from "antd";
|
||||
import {
|
||||
ArrowRightOutlined,
|
||||
SearchOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ProductMatch } from "../../services/types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
matches: ProductMatch[];
|
||||
onDeleteMatch?: (rawName: string) => void;
|
||||
onEditMatch?: (match: ProductMatch) => void;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
export const MatchList: React.FC<Props> = ({ matches }) => {
|
||||
const [searchText, setSearchText] = React.useState('');
|
||||
export const MatchList: React.FC<Props> = ({
|
||||
matches,
|
||||
onDeleteMatch,
|
||||
onEditMatch,
|
||||
isDeleting = false,
|
||||
}) => {
|
||||
const [searchText, setSearchText] = React.useState("");
|
||||
|
||||
const filteredData = matches.filter(item => {
|
||||
const raw = (item.raw_name || '').toLowerCase();
|
||||
const filteredData = matches.filter((item) => {
|
||||
const raw = (item.raw_name || "").toLowerCase();
|
||||
const prod = item.product;
|
||||
const prodName = (prod?.name || '').toLowerCase();
|
||||
const prodName = (prod?.name || "").toLowerCase();
|
||||
const search = searchText.toLowerCase();
|
||||
return raw.includes(search) || prodName.includes(search);
|
||||
});
|
||||
@@ -24,10 +37,10 @@ export const MatchList: React.FC<Props> = ({ matches }) => {
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Поиск по связям..."
|
||||
prefix={<SearchOutlined style={{ color: '#ccc' }} />}
|
||||
prefix={<SearchOutlined style={{ color: "#ccc" }} />}
|
||||
style={{ marginBottom: 12 }}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
|
||||
@@ -38,42 +51,95 @@ export const MatchList: React.FC<Props> = ({ matches }) => {
|
||||
pagination={{ pageSize: 10, size: "small", simple: true }}
|
||||
renderItem={(item) => {
|
||||
// Унификация полей (только snake_case)
|
||||
const rawName = item.raw_name || 'Без названия';
|
||||
const rawName = item.raw_name || "Без названия";
|
||||
const product = item.product;
|
||||
const productName = product?.name || 'Товар не найден';
|
||||
const productName = product?.name || "Товар не найден";
|
||||
const qty = item.quantity || 1;
|
||||
|
||||
// Логика отображения Единицы или Фасовки
|
||||
const container = item.container;
|
||||
let displayUnit = '';
|
||||
let displayUnit = "";
|
||||
|
||||
if (container) {
|
||||
// Если есть фасовка: "Пачка 180г"
|
||||
displayUnit = container.name;
|
||||
} else {
|
||||
// Иначе базовая ед.: "кг"
|
||||
displayUnit = product?.measure_unit || 'ед.';
|
||||
displayUnit = product?.measure_unit || "ед.";
|
||||
}
|
||||
|
||||
return (
|
||||
<List.Item style={{ background: '#fff', padding: 12, marginBottom: 8, borderRadius: 8 }}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<Tag color="geekblue">Чек</Tag>
|
||||
<List.Item
|
||||
style={{
|
||||
background: "#fff",
|
||||
padding: 12,
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<Tag color="geekblue">Чек</Tag>
|
||||
<Text strong>{rawName}</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#888' }}>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
color: "#888",
|
||||
}}
|
||||
>
|
||||
<ArrowRightOutlined />
|
||||
<Text>
|
||||
{productName}
|
||||
<Text strong style={{ color: '#555', marginLeft: 6 }}>
|
||||
x {qty} {displayUnit}
|
||||
{productName}
|
||||
<Text strong style={{ color: "#555", marginLeft: 6 }}>
|
||||
x {qty} {displayUnit}
|
||||
</Text>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(onDeleteMatch || onEditMatch) && (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "space-between" }}
|
||||
>
|
||||
{onEditMatch && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => onEditMatch(item)}
|
||||
size="small"
|
||||
>
|
||||
Редактировать
|
||||
</Button>
|
||||
)}
|
||||
{onDeleteMatch && (
|
||||
<Popconfirm
|
||||
title="Удалить связь?"
|
||||
description="Это действие нельзя отменить"
|
||||
onConfirm={() => onDeleteMatch(rawName)}
|
||||
okText="Да"
|
||||
cancelText="Нет"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={isDeleting}
|
||||
size="small"
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,6 +36,29 @@ export const useOcr = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMatchMutation = useMutation({
|
||||
mutationFn: (rawName: string) => api.deleteMatch(rawName),
|
||||
onSuccess: () => {
|
||||
message.success('Связь удалена');
|
||||
queryClient.invalidateQueries({ queryKey: ['matches'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['unmatched'] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error('Ошибка при удалении связи');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteUnmatchedMutation = useMutation({
|
||||
mutationFn: (rawName: string) => api.deleteUnmatched(rawName),
|
||||
onSuccess: () => {
|
||||
message.success('Нераспознанная строка удалена');
|
||||
queryClient.invalidateQueries({ queryKey: ['unmatched'] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error('Ошибка при удалении нераспознанной строки');
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
catalog: catalogQuery.data || [],
|
||||
matches: matchesQuery.data || [],
|
||||
@@ -44,5 +67,9 @@ export const useOcr = () => {
|
||||
isError: catalogQuery.isError || matchesQuery.isError,
|
||||
createMatch: createMatchMutation.mutate,
|
||||
isCreating: createMatchMutation.isPending,
|
||||
deleteMatch: deleteMatchMutation.mutate,
|
||||
isDeletingMatch: deleteMatchMutation.isPending,
|
||||
deleteUnmatched: deleteUnmatchedMutation.mutate,
|
||||
isDeletingUnmatched: deleteUnmatchedMutation.isPending,
|
||||
};
|
||||
};
|
||||
@@ -2,22 +2,18 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
List,
|
||||
Typography,
|
||||
Tag,
|
||||
Spin,
|
||||
Empty,
|
||||
DatePicker,
|
||||
Flex,
|
||||
message,
|
||||
} from "antd";
|
||||
import { List, Typography, Tag, Spin, Empty, DatePicker, Flex } from "antd";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ArrowRightOutlined,
|
||||
ThunderboltFilled,
|
||||
HistoryOutlined,
|
||||
FileTextOutlined,
|
||||
CheckCircleOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
LoadingOutlined,
|
||||
CloseCircleOutlined,
|
||||
StopOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { api } from "../services/api";
|
||||
@@ -29,9 +25,9 @@ const { RangePicker } = DatePicker;
|
||||
export const DraftsList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Состояние фильтра дат: по умолчанию последние 30 дней
|
||||
// Состояние фильтра дат: по умолчанию последние 7 дней
|
||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
|
||||
dayjs().subtract(30, "day"),
|
||||
dayjs().subtract(7, "day"),
|
||||
dayjs(),
|
||||
]);
|
||||
|
||||
@@ -51,6 +47,9 @@ export const DraftsList: React.FC = () => {
|
||||
dateRange[0].format("YYYY-MM-DD"),
|
||||
dateRange[1].format("YYYY-MM-DD")
|
||||
),
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const getStatusTag = (item: UnifiedInvoice) => {
|
||||
@@ -64,26 +63,64 @@ export const DraftsList: React.FC = () => {
|
||||
|
||||
switch (item.status) {
|
||||
case "PROCESSING":
|
||||
return <Tag color="blue">Обработка</Tag>;
|
||||
return (
|
||||
<Tag icon={<LoadingOutlined />} color="blue">
|
||||
Обработка
|
||||
</Tag>
|
||||
);
|
||||
case "READY_TO_VERIFY":
|
||||
return <Tag color="orange">Проверка</Tag>;
|
||||
return (
|
||||
<Tag icon={<ExclamationCircleOutlined />} color="orange">
|
||||
Проверка
|
||||
</Tag>
|
||||
);
|
||||
case "COMPLETED":
|
||||
return <Tag color="green">Готово</Tag>;
|
||||
return (
|
||||
<Tag icon={<CheckCircleOutlined />} color="green">
|
||||
Готово
|
||||
</Tag>
|
||||
);
|
||||
case "ERROR":
|
||||
return <Tag color="red">Ошибка</Tag>;
|
||||
return (
|
||||
<Tag icon={<CloseCircleOutlined />} color="red">
|
||||
Ошибка
|
||||
</Tag>
|
||||
);
|
||||
case "CANCELED":
|
||||
return <Tag color="default">Отменен</Tag>;
|
||||
return (
|
||||
<Tag icon={<StopOutlined />} color="default">
|
||||
Отменен
|
||||
</Tag>
|
||||
);
|
||||
case "NEW":
|
||||
return (
|
||||
<Tag icon={<PlusOutlined />} color="blue">
|
||||
Новый
|
||||
</Tag>
|
||||
);
|
||||
case "PROCESSED":
|
||||
return (
|
||||
<Tag icon={<CheckCircleOutlined />} color="green">
|
||||
Обработан
|
||||
</Tag>
|
||||
);
|
||||
case "DELETED":
|
||||
return (
|
||||
<Tag icon={<DeleteOutlined />} color="red">
|
||||
Удален
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return <Tag>{item.status}</Tag>;
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvoiceClick = (item: UnifiedInvoice) => {
|
||||
if (item.type === "SYNCED") {
|
||||
message.info("История доступна только для просмотра");
|
||||
return;
|
||||
if (item.type === "DRAFT") {
|
||||
navigate("/invoice/draft/" + item.id);
|
||||
} else if (item.type === "SYNCED") {
|
||||
navigate("/invoice/view/" + item.id);
|
||||
}
|
||||
navigate(`/invoice/${item.id}`);
|
||||
};
|
||||
|
||||
if (isError) {
|
||||
@@ -143,7 +180,7 @@ export const DraftsList: React.FC = () => {
|
||||
padding: 12,
|
||||
marginBottom: 10,
|
||||
borderRadius: 12,
|
||||
cursor: isSynced ? "default" : "pointer",
|
||||
cursor: "pointer",
|
||||
border: isSynced ? "1px solid #f0f0f0" : "1px solid #e6f7ff",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.02)",
|
||||
display: "block",
|
||||
@@ -158,34 +195,44 @@ export const DraftsList: React.FC = () => {
|
||||
{item.document_number || "Без номера"}
|
||||
</Text>
|
||||
{item.is_app_created && (
|
||||
<ThunderboltFilled
|
||||
style={{ color: "#faad14" }}
|
||||
title="Создано в RMSer"
|
||||
/>
|
||||
<span title="Создано в RMSer">📱</span>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex vertical gap={2}>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{item.items_count} поз.
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{dayjs(item.date_incoming).format("DD.MM.YYYY")}
|
||||
</Text>
|
||||
</Flex>
|
||||
{item.incoming_number && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Вх. № {item.incoming_number}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
{getStatusTag(item)}
|
||||
<Flex vertical align="start" gap={4}>
|
||||
{getStatusTag(item)}
|
||||
{isSynced && item.items_preview && (
|
||||
<div>
|
||||
{item.items_preview
|
||||
.split(", ")
|
||||
.map((previewItem, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{ fontSize: 12, color: "#666" }}
|
||||
>
|
||||
{previewItem}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex justify="space-between" style={{ marginTop: 4 }}>
|
||||
<Flex gap={8} align="center">
|
||||
<FileTextOutlined style={{ color: "#888" }} />
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{item.items_count} поз.
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
•
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{dayjs(item.date_incoming).format("DD.MM.YYYY")}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex justify="space-between" align="center">
|
||||
<div></div>
|
||||
{item.store_name && (
|
||||
<Tag
|
||||
style={{
|
||||
|
||||
@@ -159,6 +159,17 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
form.setFieldValue("supplier_id", draft.supplier_id);
|
||||
if (!currentValues.comment && draft.comment)
|
||||
form.setFieldValue("comment", draft.comment);
|
||||
|
||||
// Инициализация входящего номера
|
||||
if (
|
||||
!currentValues.incoming_document_number &&
|
||||
draft.incoming_document_number
|
||||
)
|
||||
form.setFieldValue(
|
||||
"incoming_document_number",
|
||||
draft.incoming_document_number
|
||||
);
|
||||
|
||||
if (!currentValues.date_incoming)
|
||||
form.setFieldValue(
|
||||
"date_incoming",
|
||||
@@ -204,6 +215,7 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
store_id: values.store_id,
|
||||
supplier_id: values.supplier_id,
|
||||
comment: values.comment || "",
|
||||
incoming_document_number: values.incoming_document_number || "",
|
||||
});
|
||||
} catch {
|
||||
message.error("Заполните обязательные поля (Склад, Поставщик)");
|
||||
@@ -352,6 +364,18 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
{/* Входящий номер */}
|
||||
<Form.Item
|
||||
label="Входящий номер"
|
||||
name="incoming_document_number"
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<Input placeholder="№ Документа" size="middle" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={10}>
|
||||
<Col span={24}>
|
||||
<Form.Item
|
||||
label="Склад"
|
||||
name="store_id"
|
||||
|
||||
223
rmser-view/src/pages/InvoiceViewPage.tsx
Normal file
223
rmser-view/src/pages/InvoiceViewPage.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
// src/pages/InvoiceViewPage.tsx
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Spin, Alert, Button, Table, Typography, Tag, Image } from "antd";
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
FileImageOutlined,
|
||||
HistoryOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api, getStaticUrl } from "../services/api";
|
||||
import type { DraftStatus } from "../services/types";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export const InvoiceViewPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Состояние для просмотра фото чека
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
|
||||
// Запрос данных накладной
|
||||
const {
|
||||
data: invoice,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["invoice", id],
|
||||
queryFn: () => api.getInvoice(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const getStatusTag = (status: DraftStatus) => {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return <Tag color="blue">Обработка</Tag>;
|
||||
case "READY_TO_VERIFY":
|
||||
return <Tag color="orange">Проверка</Tag>;
|
||||
case "COMPLETED":
|
||||
return (
|
||||
<Tag icon={<HistoryOutlined />} color="success">
|
||||
Синхронизировано
|
||||
</Tag>
|
||||
);
|
||||
case "ERROR":
|
||||
return <Tag color="red">Ошибка</Tag>;
|
||||
case "CANCELED":
|
||||
return <Tag color="default">Отменен</Tag>;
|
||||
default:
|
||||
return <Tag>{status}</Tag>;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: 50 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !invoice) {
|
||||
return <Alert type="error" message="Ошибка загрузки накладной" />;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Товар",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
},
|
||||
{
|
||||
title: "Кол-во",
|
||||
dataIndex: "quantity",
|
||||
key: "quantity",
|
||||
align: "right" as const,
|
||||
},
|
||||
{
|
||||
title: "Цена",
|
||||
dataIndex: "price",
|
||||
key: "price",
|
||||
align: "right" as const,
|
||||
render: (price: number) =>
|
||||
price.toLocaleString("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "Сумма",
|
||||
dataIndex: "total",
|
||||
key: "total",
|
||||
align: "right" as const,
|
||||
render: (total: number) =>
|
||||
total.toLocaleString("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const totalSum = (invoice.items || []).reduce(
|
||||
(acc, item) => acc + item.total,
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ paddingBottom: 20 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
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",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
№{invoice.number}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{invoice.date} • {invoice.supplier.name}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
{getStatusTag(invoice.status)}
|
||||
|
||||
{/* Кнопка просмотра чека (только если есть URL) */}
|
||||
{invoice.photo_url && (
|
||||
<Button
|
||||
icon={<FileImageOutlined />}
|
||||
onClick={() => setPreviewVisible(true)}
|
||||
size="small"
|
||||
>
|
||||
Чек
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Таблица товаров */}
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Title level={5} style={{ marginBottom: 16 }}>
|
||||
Товары ({(invoice.items || []).length} поз.)
|
||||
</Title>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={invoice.items || []}
|
||||
pagination={false}
|
||||
rowKey="name"
|
||||
size="small"
|
||||
summary={() => (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={3}>
|
||||
<Text strong>Итого:</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={3} align="right">
|
||||
<Text strong>
|
||||
{totalSum.toLocaleString("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
})}
|
||||
</Text>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Скрытый компонент для просмотра изображения */}
|
||||
{invoice.photo_url && (
|
||||
<div style={{ display: "none" }}>
|
||||
<Image.PreviewGroup
|
||||
preview={{
|
||||
visible: previewVisible,
|
||||
onVisibleChange: (vis) => setPreviewVisible(vis),
|
||||
movable: true,
|
||||
scaleStep: 0.5,
|
||||
}}
|
||||
>
|
||||
<Image src={getStaticUrl(invoice.photo_url)} />
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,52 +1,104 @@
|
||||
import React from 'react';
|
||||
import { Spin, Alert } from 'antd';
|
||||
import { useOcr } from '../hooks/useOcr';
|
||||
import { AddMatchForm } from '../components/ocr/AddMatchForm';
|
||||
import { MatchList } from '../components/ocr/MatchList';
|
||||
import React from "react";
|
||||
import { Spin, Alert } from "antd";
|
||||
import { useOcr } from "../hooks/useOcr";
|
||||
import { AddMatchForm } from "../components/ocr/AddMatchForm";
|
||||
import { MatchList } from "../components/ocr/MatchList";
|
||||
|
||||
export const OcrLearning: React.FC = () => {
|
||||
const {
|
||||
catalog,
|
||||
const {
|
||||
catalog,
|
||||
matches,
|
||||
unmatched,
|
||||
isLoading,
|
||||
isError,
|
||||
createMatch,
|
||||
isCreating
|
||||
unmatched,
|
||||
isLoading,
|
||||
isError,
|
||||
createMatch,
|
||||
isCreating,
|
||||
deleteMatch,
|
||||
isDeletingMatch,
|
||||
deleteUnmatched,
|
||||
} = useOcr();
|
||||
|
||||
// Состояние для редактирования
|
||||
const [editingMatch, setEditingMatch] = React.useState<string | null>(null);
|
||||
|
||||
// Найти редактируемую связь
|
||||
const currentEditingMatch = React.useMemo(() => {
|
||||
if (!editingMatch) return undefined;
|
||||
return matches.find((match) => match.raw_name === editingMatch);
|
||||
}, [editingMatch, matches]);
|
||||
|
||||
if (isLoading && matches.length === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '50vh', flexDirection: 'column', gap: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "50vh",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Spin size="large" />
|
||||
<span style={{ color: '#888' }}>Загрузка справочников...</span>
|
||||
<span style={{ color: "#888" }}>Загрузка справочников...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert message="Ошибка" description="Не удалось загрузить данные." type="error" showIcon style={{ margin: 16 }} />
|
||||
<Alert
|
||||
message="Ошибка"
|
||||
description="Не удалось загрузить данные."
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ margin: 16 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ paddingBottom: 20 }}>
|
||||
<AddMatchForm
|
||||
<AddMatchForm
|
||||
catalog={catalog}
|
||||
unmatched={unmatched}
|
||||
// Передаем containerId
|
||||
onSave={(raw, prodId, qty, contId) => createMatch({
|
||||
raw_name: raw,
|
||||
product_id: prodId,
|
||||
quantity: qty,
|
||||
container_id: contId
|
||||
})}
|
||||
onSave={(raw, prodId, qty, contId) => {
|
||||
if (currentEditingMatch) {
|
||||
// Обновление существующей связи
|
||||
createMatch({
|
||||
raw_name: raw,
|
||||
product_id: prodId,
|
||||
quantity: qty,
|
||||
container_id: contId,
|
||||
});
|
||||
setEditingMatch(null);
|
||||
} else {
|
||||
// Создание новой связи
|
||||
createMatch({
|
||||
raw_name: raw,
|
||||
product_id: prodId,
|
||||
quantity: qty,
|
||||
container_id: contId,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onDeleteUnmatched={deleteUnmatched}
|
||||
isLoading={isCreating}
|
||||
initialValues={currentEditingMatch}
|
||||
onCancelEdit={() => setEditingMatch(null)}
|
||||
/>
|
||||
<h3 style={{ marginLeft: 4, marginBottom: 12 }}>
|
||||
Обученные позиции ({matches.length})
|
||||
</h3>
|
||||
<MatchList
|
||||
matches={matches}
|
||||
onDeleteMatch={deleteMatch}
|
||||
onEditMatch={(match) => {
|
||||
setEditingMatch(match.raw_name);
|
||||
}}
|
||||
isDeleting={isDeletingMatch}
|
||||
/>
|
||||
|
||||
<h3 style={{ marginLeft: 4, marginBottom: 12 }}>Обученные позиции ({matches.length})</h3>
|
||||
<MatchList matches={matches} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -24,7 +24,8 @@ import type {
|
||||
DictionariesResponse,
|
||||
UnifiedInvoice,
|
||||
ServerUser,
|
||||
UserRole
|
||||
UserRole,
|
||||
InvoiceDetails
|
||||
} from './types';
|
||||
|
||||
// Базовый URL
|
||||
@@ -134,6 +135,18 @@ export const api = {
|
||||
const { data } = await apiClient.post<{ status: string }>('/ocr/match', payload);
|
||||
return data;
|
||||
},
|
||||
deleteMatch: async (rawName: string): Promise<{ status: string }> => {
|
||||
const { data } = await apiClient.delete<{ status: string }>('/ocr/match', {
|
||||
params: { raw_name: rawName }
|
||||
});
|
||||
return data;
|
||||
},
|
||||
deleteUnmatched: async (rawName: string): Promise<{ status: string }> => {
|
||||
const { data } = await apiClient.delete<{ status: string }>('/ocr/unmatched', {
|
||||
params: { raw_name: rawName }
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
createInvoice: async (payload: CreateInvoiceRequest): Promise<InvoiceResponse> => {
|
||||
const { data } = await apiClient.post<InvoiceResponse>('/invoices/send', payload);
|
||||
@@ -233,4 +246,9 @@ export const api = {
|
||||
const { data } = await apiClient.delete<{ status: string }>(`/settings/users/${userId}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
getInvoice: async (id: string): Promise<InvoiceDetails> => {
|
||||
const { data } = await apiClient.get<InvoiceDetails>(`/invoices/${id}`);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -166,7 +166,7 @@ export interface ProductGroup {
|
||||
|
||||
// --- Черновик Накладной (Draft) ---
|
||||
|
||||
export type DraftStatus = 'PROCESSING' | 'READY_TO_VERIFY' | 'COMPLETED' | 'ERROR' | 'CANCELED';
|
||||
export type DraftStatus = 'PROCESSING' | 'READY_TO_VERIFY' | 'COMPLETED' | 'ERROR' | 'CANCELED' | 'NEW' | 'PROCESSED' | 'DELETED';
|
||||
|
||||
export interface DraftItem {
|
||||
id: UUID;
|
||||
@@ -205,6 +205,7 @@ export interface DraftInvoice {
|
||||
id: UUID;
|
||||
status: DraftStatus;
|
||||
document_number: string;
|
||||
incoming_document_number?: string
|
||||
date_incoming: string | null; // YYYY-MM-DD
|
||||
store_id: UUID | null;
|
||||
supplier_id: UUID | null;
|
||||
@@ -228,6 +229,7 @@ export interface CommitDraftRequest {
|
||||
store_id: UUID;
|
||||
supplier_id: UUID;
|
||||
comment: string;
|
||||
incoming_document_number?: string;
|
||||
}
|
||||
export interface MainUnit {
|
||||
id: UUID;
|
||||
@@ -249,4 +251,21 @@ export interface UnifiedInvoice {
|
||||
store_name?: string;
|
||||
created_at: string;
|
||||
is_app_created: boolean; // Создано ли через наше приложение
|
||||
items_preview: string; // Краткое содержание товаров
|
||||
photo_url: string | null; // Ссылка на фото чека
|
||||
}
|
||||
|
||||
export interface InvoiceDetails {
|
||||
id: UUID;
|
||||
number: string;
|
||||
date: string;
|
||||
status: DraftStatus;
|
||||
supplier: Supplier;
|
||||
items: {
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
total: number;
|
||||
}[];
|
||||
photo_url: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user