mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
Настройки работают
Иерархия групп работает Полностью завязано на пользователя и серверы
This commit is contained in:
@@ -1,16 +1,25 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Result, Button } from 'antd';
|
||||
import { Providers } from './components/layout/Providers';
|
||||
import { AppLayout } from './components/layout/AppLayout';
|
||||
import { OcrLearning } from './pages/OcrLearning';
|
||||
import { InvoiceDraftPage } from './pages/InvoiceDraftPage';
|
||||
import { DraftsList } from './pages/DraftsList';
|
||||
import { UNAUTHORIZED_EVENT } from './services/api';
|
||||
import { useEffect, useState } from "react";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { Result, Button } from "antd";
|
||||
import { Providers } from "./components/layout/Providers";
|
||||
import { AppLayout } from "./components/layout/AppLayout";
|
||||
import { OcrLearning } from "./pages/OcrLearning";
|
||||
import { InvoiceDraftPage } from "./pages/InvoiceDraftPage";
|
||||
import { DraftsList } from "./pages/DraftsList";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { UNAUTHORIZED_EVENT } from "./services/api";
|
||||
|
||||
// Компонент заглушки для 401 ошибки
|
||||
const UnauthorizedScreen = () => (
|
||||
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fff' }}>
|
||||
<div
|
||||
style={{
|
||||
height: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<Result
|
||||
status="403"
|
||||
title="Доступ запрещен"
|
||||
@@ -29,7 +38,7 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
const handleUnauthorized = () => setIsUnauthorized(true);
|
||||
|
||||
|
||||
// Подписываемся на событие из api.ts
|
||||
window.addEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
|
||||
|
||||
@@ -48,12 +57,13 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<AppLayout />}>
|
||||
{/* Если Dashboard удален, можно сделать редирект на invoices */}
|
||||
<Route index element={<Navigate to="/invoices" replace />} />
|
||||
|
||||
<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="settings" element={<SettingsPage />} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
@@ -62,4 +72,4 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -1,48 +1,86 @@
|
||||
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 { CreateContainerModal } from './CreateContainerModal';
|
||||
import type { DraftItem, UpdateDraftItemRequest, ProductSearchResult, ProductContainer, Recommendation } from '../../services/types';
|
||||
import React, { useMemo, useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
Flex,
|
||||
InputNumber,
|
||||
Typography,
|
||||
Select,
|
||||
Tag,
|
||||
Button,
|
||||
Divider,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
} from "antd";
|
||||
import {
|
||||
SyncOutlined,
|
||||
PlusOutlined,
|
||||
WarningFilled,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { CatalogSelect } from "../ocr/CatalogSelect";
|
||||
import { CreateContainerModal } from "./CreateContainerModal";
|
||||
import type {
|
||||
DraftItem,
|
||||
UpdateDraftItemRequest,
|
||||
ProductSearchResult,
|
||||
ProductContainer,
|
||||
Recommendation,
|
||||
} from "../../services/types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
item: DraftItem;
|
||||
onUpdate: (itemId: string, changes: UpdateDraftItemRequest) => void;
|
||||
onDelete: (itemId: string) => void;
|
||||
isUpdating: boolean;
|
||||
recommendations?: Recommendation[]; // Новый проп
|
||||
recommendations?: Recommendation[];
|
||||
}
|
||||
|
||||
export const DraftItemRow: React.FC<Props> = ({ item, onUpdate, isUpdating, recommendations = [] }) => {
|
||||
export const DraftItemRow: React.FC<Props> = ({
|
||||
item,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
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);
|
||||
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('.', ','));
|
||||
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('.', ','));
|
||||
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 [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;
|
||||
if (searchedProduct && searchedProduct.id === item.product_id)
|
||||
return searchedProduct;
|
||||
return item.product as unknown as ProductSearchResult | undefined;
|
||||
}, [searchedProduct, item.product, item.product_id]);
|
||||
|
||||
@@ -51,28 +89,35 @@ export const DraftItemRow: React.FC<Props> = ({ item, onUpdate, isUpdating, reco
|
||||
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);
|
||||
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 || 'ед.';
|
||||
const baseUom =
|
||||
activeProduct?.main_unit?.name || activeProduct?.measure_unit || "ед.";
|
||||
|
||||
const containerOptions = useMemo(() => {
|
||||
if (!activeProduct) return [];
|
||||
const opts = [
|
||||
{ value: 'BASE_UNIT', label: `Базовая (${baseUom})` },
|
||||
...containers.map(c => ({
|
||||
{ value: "BASE_UNIT", label: `Базовая (${baseUom})` },
|
||||
...containers.map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.name} (=${Number(c.count)} ${baseUom})`
|
||||
}))
|
||||
label: `${c.name} (=${Number(c.count)} ${baseUom})`,
|
||||
})),
|
||||
];
|
||||
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})`
|
||||
});
|
||||
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]);
|
||||
@@ -80,123 +125,193 @@ export const DraftItemRow: React.FC<Props> = ({ item, onUpdate, isUpdating, reco
|
||||
// --- WARNING LOGIC ---
|
||||
const activeWarning = useMemo(() => {
|
||||
if (!item.product_id) return null;
|
||||
return recommendations.find(r => r.ProductID === item.product_id);
|
||||
return recommendations.find((r) => r.ProductID === item.product_id);
|
||||
}, [item.product_id, recommendations]);
|
||||
|
||||
const showWarningModal = () => {
|
||||
if (!activeWarning) return;
|
||||
Modal.warning({
|
||||
title: 'Внимание: проблемный товар',
|
||||
title: "Внимание: проблемный товар",
|
||||
content: (
|
||||
<div>
|
||||
<p><b>{activeWarning.ProductName}</b></p>
|
||||
<p>
|
||||
<b>{activeWarning.ProductName}</b>
|
||||
</p>
|
||||
<p>{activeWarning.Reason}</p>
|
||||
<p><Tag color="orange">{activeWarning.Type}</Tag></p>
|
||||
<p>
|
||||
<Tag color="orange">{activeWarning.Type}</Tag>
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: 'Понятно',
|
||||
maskClosable: true
|
||||
okText: "Понятно",
|
||||
maskClosable: true,
|
||||
});
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
const parseToNum = (val: string | null | undefined): number => {
|
||||
if (!val) return 0;
|
||||
return parseFloat(val.replace(',', '.'));
|
||||
return parseFloat(val.replace(",", "."));
|
||||
};
|
||||
|
||||
const getUpdatePayload = (overrides: Partial<UpdateDraftItemRequest>): UpdateDraftItemRequest => {
|
||||
const currentQty = localQuantity !== null ? parseToNum(localQuantity) : item.quantity;
|
||||
const currentPrice = localPrice !== null ? parseToNum(localPrice) : item.price;
|
||||
const getUpdatePayload = (
|
||||
overrides: Partial<UpdateDraftItemRequest>
|
||||
): UpdateDraftItemRequest => {
|
||||
const currentQty =
|
||||
localQuantity !== null ? parseToNum(localQuantity) : item.quantity;
|
||||
const currentPrice =
|
||||
localPrice !== null ? parseToNum(localPrice) : item.price;
|
||||
|
||||
return {
|
||||
product_id: item.product_id || undefined,
|
||||
product_id: item.product_id || undefined,
|
||||
container_id: item.container_id,
|
||||
quantity: currentQty ?? 1,
|
||||
price: currentPrice ?? 0,
|
||||
...overrides
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
// --- Handlers ---
|
||||
const handleProductChange = (prodId: string, productObj?: ProductSearchResult) => {
|
||||
const handleProductChange = (
|
||||
prodId: string,
|
||||
productObj?: ProductSearchResult
|
||||
) => {
|
||||
if (productObj) setSearchedProduct(productObj);
|
||||
onUpdate(item.id, getUpdatePayload({ product_id: prodId, container_id: null }));
|
||||
onUpdate(
|
||||
item.id,
|
||||
getUpdatePayload({ product_id: prodId, container_id: null })
|
||||
);
|
||||
};
|
||||
|
||||
const handleContainerChange = (val: string) => {
|
||||
const newVal = val === 'BASE_UNIT' ? null : val;
|
||||
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;
|
||||
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 }));
|
||||
onUpdate(item.id, getUpdatePayload({ [field]: numVal }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleContainerCreated = (newContainer: ProductContainer) => {
|
||||
setIsModalOpen(false);
|
||||
if (activeProduct) {
|
||||
setAddedContainers(prev => ({ ...prev, [activeProduct.id]: [...(prev[activeProduct.id] || []), newContainer] }));
|
||||
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 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' }}
|
||||
<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>
|
||||
{/* Показываем raw_name только если это OCR строка. Если создана вручную и пустая - плейсхолдер */}
|
||||
<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>
|
||||
<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' }} />}
|
||||
|
||||
<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' }}
|
||||
<WarningFilled
|
||||
style={{ color: "#faad14", fontSize: 16, cursor: "pointer" }}
|
||||
onClick={showWarningModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!item.product_id && <Tag color="error" style={{ margin: 0 }}>?</Tag>}
|
||||
|
||||
{!item.product_id && (
|
||||
<Tag color="error" style={{ margin: 0 }}>
|
||||
?
|
||||
</Tag>
|
||||
)}
|
||||
|
||||
{/* Кнопка удаления */}
|
||||
<Popconfirm
|
||||
title="Удалить строку?"
|
||||
onConfirm={() => onDelete(item.id)}
|
||||
okText="Да"
|
||||
cancelText="Нет"
|
||||
placement="left"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
danger
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
<CatalogSelect
|
||||
<CatalogSelect
|
||||
value={item.product_id || undefined}
|
||||
onChange={handleProductChange}
|
||||
initialProduct={activeProduct}
|
||||
initialProduct={activeProduct}
|
||||
/>
|
||||
|
||||
{activeProduct && (
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Выберите единицу измерения"
|
||||
options={containerOptions}
|
||||
value={item.container_id || 'BASE_UNIT'}
|
||||
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' }}>
|
||||
<Divider style={{ margin: "4px 0" }} />
|
||||
<Button
|
||||
type="text"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
style={{ textAlign: "left" }}
|
||||
>
|
||||
Добавить фасовку...
|
||||
</Button>
|
||||
</>
|
||||
@@ -204,27 +319,52 @@ export const DraftItemRow: React.FC<Props> = ({ item, onUpdate, isUpdating, reco
|
||||
/>
|
||||
)}
|
||||
|
||||
<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
|
||||
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>
|
||||
@@ -240,4 +380,4 @@ export const DraftItemRow: React.FC<Props> = ({ item, onUpdate, isUpdating, reco
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,80 +1,111 @@
|
||||
import React from 'react';
|
||||
import { Layout, theme } from 'antd';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { ScanOutlined, FileTextOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import React from "react";
|
||||
import { Layout, theme } from "antd";
|
||||
import { Outlet, useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
ScanOutlined,
|
||||
FileTextOutlined,
|
||||
SettingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
export const AppLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
|
||||
const {
|
||||
token: { colorBgContainer, colorPrimary, colorTextSecondary },
|
||||
} = theme.useToken();
|
||||
|
||||
const path = location.pathname;
|
||||
let activeKey = 'invoices';
|
||||
if (path.startsWith('/ocr')) activeKey = 'ocr';
|
||||
else if (path.startsWith('/settings')) activeKey = 'settings';
|
||||
let activeKey = "invoices";
|
||||
if (path.startsWith("/ocr")) activeKey = "ocr";
|
||||
else if (path.startsWith("/settings")) activeKey = "settings";
|
||||
|
||||
const menuItems = [
|
||||
{ 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: '#' },
|
||||
{
|
||||
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: "/settings",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<Layout
|
||||
style={{ minHeight: "100vh", display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{/* Верхнюю шапку (Header) удалили для экономии места */}
|
||||
|
||||
<Content style={{ padding: '0', flex: 1, overflowY: 'auto', marginBottom: 60 }}>
|
||||
<Content
|
||||
style={{ padding: "0", flex: 1, overflowY: "auto", marginBottom: 60 }}
|
||||
>
|
||||
{/* Убрали лишние паддинги вокруг контента для мобилок */}
|
||||
<div
|
||||
style={{
|
||||
background: colorBgContainer,
|
||||
minHeight: '100%',
|
||||
padding: '12px 12px 80px 12px', // Добавили отступ снизу, чтобы контент не перекрывался меню
|
||||
minHeight: "100%",
|
||||
padding: "12px 12px 80px 12px", // Добавили отступ снизу, чтобы контент не перекрывался меню
|
||||
borderRadius: 0, // На мобильных скругления углов всего экрана обычно не нужны
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Content>
|
||||
|
||||
|
||||
{/* Нижний Таб-бар */}
|
||||
<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 => {
|
||||
<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
|
||||
<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
|
||||
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 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
marginTop: 2,
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
@@ -83,4 +114,4 @@ export const AppLayout: React.FC = () => {
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
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, Modal, Tag
|
||||
} from 'antd';
|
||||
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';
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
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,
|
||||
Modal,
|
||||
Tag,
|
||||
} from "antd";
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
ExclamationCircleFilled,
|
||||
RestOutlined,
|
||||
PlusOutlined,
|
||||
} 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 { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
@@ -20,27 +42,29 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
|
||||
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// --- ЗАПРОСЫ ---
|
||||
|
||||
// Получаем сразу все справочники одним запросом
|
||||
const dictQuery = useQuery({
|
||||
queryKey: ['dictionaries'],
|
||||
|
||||
const dictQuery = useQuery({
|
||||
queryKey: ["dictionaries"],
|
||||
queryFn: api.getDictionaries,
|
||||
staleTime: 1000 * 60 * 5 // Кэшируем на 5 минут
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
|
||||
const recommendationsQuery = useQuery({
|
||||
queryKey: ["recommendations"],
|
||||
queryFn: api.getRecommendations,
|
||||
});
|
||||
|
||||
const recommendationsQuery = useQuery({ queryKey: ['recommendations'], queryFn: api.getRecommendations });
|
||||
|
||||
const draftQuery = useQuery({
|
||||
queryKey: ['draft', id],
|
||||
queryKey: ["draft", id],
|
||||
queryFn: () => api.getDraft(id!),
|
||||
enabled: !!id,
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
return status === 'PROCESSING' ? 3000 : false;
|
||||
return status === "PROCESSING" ? 3000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,111 +72,154 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
const stores = dictQuery.data?.stores || [];
|
||||
const suppliers = dictQuery.data?.suppliers || [];
|
||||
|
||||
// --- МУТАЦИИ ---
|
||||
|
||||
const updateItemMutation = useMutation({
|
||||
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
|
||||
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
|
||||
api.updateDraftItem(id!, vars.itemId, vars.payload),
|
||||
onMutate: async ({ itemId }) => {
|
||||
setUpdatingItems(prev => new Set(prev).add(itemId));
|
||||
setUpdatingItems((prev) => new Set(prev).add(itemId));
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['draft', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ["draft", id] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error('Не удалось сохранить строку');
|
||||
message.error("Не удалось сохранить строку");
|
||||
},
|
||||
onSettled: (_data, _err, vars) => {
|
||||
setUpdatingItems(prev => {
|
||||
setUpdatingItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(vars.itemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ДОБАВЛЕНО: Добавление строки
|
||||
const addItemMutation = useMutation({
|
||||
mutationFn: () => api.addDraftItem(id!),
|
||||
onSuccess: () => {
|
||||
message.success("Строка добавлена");
|
||||
queryClient.invalidateQueries({ queryKey: ["draft", id] });
|
||||
// Можно сделать скролл вниз, но пока оставим как есть
|
||||
},
|
||||
onError: () => {
|
||||
message.error("Ошибка создания строки");
|
||||
},
|
||||
});
|
||||
|
||||
// ДОБАВЛЕНО: Удаление строки
|
||||
const deleteItemMutation = useMutation({
|
||||
mutationFn: (itemId: string) => api.deleteDraftItem(id!, itemId),
|
||||
onSuccess: () => {
|
||||
message.success("Строка удалена");
|
||||
queryClient.invalidateQueries({ queryKey: ["draft", id] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("Ошибка удаления строки");
|
||||
},
|
||||
});
|
||||
|
||||
const commitMutation = useMutation({
|
||||
mutationFn: (payload: CommitDraftRequest) => api.commitDraft(id!, payload),
|
||||
onSuccess: (data) => {
|
||||
message.success(`Накладная ${data.document_number} создана!`);
|
||||
navigate('/invoices');
|
||||
navigate("/invoices");
|
||||
},
|
||||
onError: () => {
|
||||
message.error('Ошибка при создании накладной');
|
||||
}
|
||||
message.error("Ошибка при создании накладной");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteDraftMutation = useMutation({
|
||||
mutationFn: () => api.deleteDraft(id!),
|
||||
onSuccess: () => {
|
||||
if (draft?.status === 'CANCELED') {
|
||||
message.info('Черновик удален окончательно');
|
||||
navigate('/invoices');
|
||||
if (draft?.status === "CANCELED") {
|
||||
message.info("Черновик удален окончательно");
|
||||
navigate("/invoices");
|
||||
} else {
|
||||
message.warning('Черновик отменен');
|
||||
queryClient.invalidateQueries({ queryKey: ['draft', id] });
|
||||
message.warning("Черновик отменен");
|
||||
queryClient.invalidateQueries({ queryKey: ["draft", id] });
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
message.error('Ошибка при удалении');
|
||||
}
|
||||
message.error("Ошибка при удалении");
|
||||
},
|
||||
});
|
||||
|
||||
// --- ЭФФЕКТЫ ---
|
||||
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]);
|
||||
|
||||
// --- ХЕЛПЕРЫ ---
|
||||
const totalSum = useMemo(() => {
|
||||
return draft?.items.reduce((acc, item) => acc + (Number(item.quantity) * Number(item.price)), 0) || 0;
|
||||
}, [draft?.items]);
|
||||
|
||||
const invalidItemsCount = useMemo(() => {
|
||||
return draft?.items.filter(i => !i.product_id).length || 0;
|
||||
return (
|
||||
draft?.items.reduce(
|
||||
(acc, item) => acc + Number(item.quantity) * Number(item.price),
|
||||
0
|
||||
) || 0
|
||||
);
|
||||
}, [draft?.items]);
|
||||
|
||||
const handleItemUpdate = (itemId: string, changes: UpdateDraftItemRequest) => {
|
||||
const invalidItemsCount = useMemo(() => {
|
||||
return draft?.items.filter((i) => !i.product_id).length || 0;
|
||||
}, [draft?.items]);
|
||||
|
||||
const handleItemUpdate = (
|
||||
itemId: string,
|
||||
changes: UpdateDraftItemRequest
|
||||
) => {
|
||||
updateItemMutation.mutate({ itemId, payload: changes });
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
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'),
|
||||
date_incoming: values.date_incoming.format("YYYY-MM-DD"),
|
||||
store_id: values.store_id,
|
||||
supplier_id: values.supplier_id,
|
||||
comment: values.comment || '',
|
||||
comment: values.comment || "",
|
||||
});
|
||||
} catch {
|
||||
message.error('Заполните обязательные поля (Склад, Поставщик)');
|
||||
message.error("Заполните обязательные поля (Склад, Поставщик)");
|
||||
}
|
||||
};
|
||||
|
||||
const isCanceled = draft?.status === 'CANCELED';
|
||||
const isCanceled = draft?.status === "CANCELED";
|
||||
|
||||
const handleDelete = () => {
|
||||
confirm({
|
||||
title: isCanceled ? 'Удалить окончательно?' : 'Отменить черновик?',
|
||||
icon: <ExclamationCircleFilled style={{ color: 'red' }} />,
|
||||
content: isCanceled
|
||||
? 'Черновик пропадет из списка навсегда.'
|
||||
title: isCanceled ? "Удалить окончательно?" : "Отменить черновик?",
|
||||
icon: <ExclamationCircleFilled style={{ color: "red" }} />,
|
||||
content: isCanceled
|
||||
? "Черновик пропадет из списка навсегда."
|
||||
: 'Черновик получит статус "Отменен", но останется в списке.',
|
||||
okText: isCanceled ? 'Удалить навсегда' : 'Отменить',
|
||||
okType: 'danger',
|
||||
cancelText: 'Назад',
|
||||
okText: isCanceled ? "Удалить навсегда" : "Отменить",
|
||||
okType: "danger",
|
||||
cancelText: "Назад",
|
||||
onOk() {
|
||||
deleteDraftMutation.mutate();
|
||||
},
|
||||
@@ -160,10 +227,17 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// --- RENDER ---
|
||||
const showSpinner = draftQuery.isLoading || (draft?.status === 'PROCESSING' && (!draft?.items || draft.items.length === 0));
|
||||
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" /></div>;
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: 50 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (draftQuery.isError || !draft) {
|
||||
@@ -173,118 +247,235 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
return (
|
||||
<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
|
||||
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}
|
||||
|
||||
<Button
|
||||
danger={isCanceled}
|
||||
type={isCanceled ? "primary" : "default"}
|
||||
icon={isCanceled ? <DeleteOutlined /> : <RestOutlined />}
|
||||
onClick={handleDelete}
|
||||
loading={deleteDraftMutation.isPending}
|
||||
size="small"
|
||||
>
|
||||
{isCanceled ? 'Удалить' : 'Отмена'}
|
||||
{isCanceled ? "Удалить" : "Отмена"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Form: Склады и Поставщики */}
|
||||
<div style={{ background: '#fff', padding: 12, borderRadius: 8, marginBottom: 12, opacity: isCanceled ? 0.6 : 1 }}>
|
||||
<Form form={form} layout="vertical" initialValues={{ date_incoming: dayjs() }}>
|
||||
<Row gutter={10}>
|
||||
<Col span={12}>
|
||||
<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: 'Выберите склад' }]} style={{ marginBottom: 8 }}>
|
||||
<Select
|
||||
placeholder="Куда?"
|
||||
loading={dictQuery.isLoading}
|
||||
options={stores.map(s => ({ label: s.name, value: s.id }))}
|
||||
size="middle"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
{/* Поле Поставщика (Обязательное) */}
|
||||
<Form.Item label="Поставщик" name="supplier_id" rules={[{ required: true, message: 'Выберите поставщика' }]} style={{ marginBottom: 8 }}>
|
||||
<Select
|
||||
placeholder="От кого?"
|
||||
loading={dictQuery.isLoading}
|
||||
options={suppliers.map(s => ({ label: s.name, value: s.id }))}
|
||||
size="middle"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Комментарий" name="comment" style={{ marginBottom: 0 }}>
|
||||
<TextArea rows={1} placeholder="Комментарий..." style={{ fontSize: 13 }} />
|
||||
</Form.Item>
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
opacity: isCanceled ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ date_incoming: dayjs() }}
|
||||
>
|
||||
<Row gutter={10}>
|
||||
<Col span={12}>
|
||||
<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: "Выберите склад" }]}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<Select
|
||||
placeholder="Куда?"
|
||||
loading={dictQuery.isLoading}
|
||||
options={stores.map((s) => ({ label: s.name, value: s.id }))}
|
||||
size="middle"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item
|
||||
label="Поставщик"
|
||||
name="supplier_id"
|
||||
rules={[{ required: true, message: "Выберите поставщика" }]}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<Select
|
||||
placeholder="От кого?"
|
||||
loading={dictQuery.isLoading}
|
||||
options={suppliers.map((s) => ({ label: s.name, value: s.id }))}
|
||||
size="middle"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? "")
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Комментарий"
|
||||
name="comment"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<TextArea
|
||||
rows={1}
|
||||
placeholder="Комментарий..."
|
||||
style={{ fontSize: 13 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
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}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{draft.items.map((item) => (
|
||||
<DraftItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onUpdate={handleItemUpdate}
|
||||
// Передаем обработчик удаления
|
||||
onDelete={(itemId) => deleteItemMutation.mutate(itemId)}
|
||||
isUpdating={updatingItems.has(item.id)}
|
||||
recommendations={recommendationsQuery.data || []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Кнопка добавления позиции */}
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
style={{ marginTop: 12, marginBottom: 80, height: 48 }} // Увеличенный margin bottom для Affix
|
||||
onClick={() => addItemMutation.mutate()}
|
||||
loading={addItemMutation.isPending}
|
||||
disabled={isCanceled}
|
||||
>
|
||||
Добавить товар
|
||||
</Button>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<Affix offsetBottom={60}>
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
padding: '8px 16px',
|
||||
borderTop: '1px solid #eee',
|
||||
boxShadow: '0 -2px 10px rgba(0,0,0,0.05)',
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
borderRadius: '8px 8px 0 0'
|
||||
}}>
|
||||
<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"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={handleCommit}
|
||||
loading={commitMutation.isPending}
|
||||
disabled={invalidItemsCount > 0 || isCanceled}
|
||||
style={{ height: 40, padding: '0 24px' }}
|
||||
>
|
||||
{isCanceled ? 'Восстановить' : 'Отправить'}
|
||||
</Button>
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
padding: "8px 16px",
|
||||
borderTop: "1px solid #eee",
|
||||
boxShadow: "0 -2px 10px rgba(0,0,0,0.05)",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
borderRadius: "8px 8px 0 0",
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={handleCommit}
|
||||
loading={commitMutation.isPending}
|
||||
disabled={invalidItemsCount > 0 || isCanceled}
|
||||
style={{ height: 40, padding: "0 24px" }}
|
||||
>
|
||||
{isCanceled ? "Восстановить" : "Отправить"}
|
||||
</Button>
|
||||
</div>
|
||||
</Affix>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
235
rmser-view/src/pages/SettingsPage.tsx
Normal file
235
rmser-view/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React, { useEffect } from "react";
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
Form,
|
||||
Select,
|
||||
Switch,
|
||||
Button,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
TreeSelect,
|
||||
Spin,
|
||||
message,
|
||||
} from "antd";
|
||||
import {
|
||||
SaveOutlined,
|
||||
BarChartOutlined,
|
||||
SettingOutlined,
|
||||
FolderOpenOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../services/api";
|
||||
import type { UserSettings } from "../services/types";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// --- Запросы ---
|
||||
|
||||
const settingsQuery = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: api.getSettings,
|
||||
});
|
||||
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ["stats"],
|
||||
queryFn: api.getStats,
|
||||
});
|
||||
|
||||
const dictQuery = useQuery({
|
||||
queryKey: ["dictionaries"],
|
||||
queryFn: api.getDictionaries,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
|
||||
const groupsQuery = useQuery({
|
||||
queryKey: ["productGroups"],
|
||||
queryFn: api.getProductGroups,
|
||||
staleTime: 1000 * 60 * 10,
|
||||
});
|
||||
|
||||
// --- Мутации ---
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (vals: UserSettings) => api.updateSettings(vals),
|
||||
onSuccess: () => {
|
||||
message.success("Настройки сохранены");
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("Не удалось сохранить настройки");
|
||||
},
|
||||
});
|
||||
|
||||
// --- Эффекты ---
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsQuery.data) {
|
||||
form.setFieldsValue(settingsQuery.data);
|
||||
}
|
||||
}, [settingsQuery.data, form]);
|
||||
|
||||
// --- Хендлеры ---
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
saveMutation.mutate(values);
|
||||
} catch {
|
||||
// Ошибки валидации
|
||||
}
|
||||
};
|
||||
|
||||
// --- Рендер ---
|
||||
|
||||
if (settingsQuery.isLoading) {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: 50 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "0 16px 80px" }}>
|
||||
<Title level={4} style={{ marginTop: 16 }}>
|
||||
<SettingOutlined /> Настройки
|
||||
</Title>
|
||||
|
||||
{/* Статистика */}
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: "#f0f5ff",
|
||||
borderColor: "#d6e4ff",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<BarChartOutlined style={{ color: "#1890ff" }} />
|
||||
<Text strong>Статистика накладных</Text>
|
||||
</div>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="За 24ч"
|
||||
value={statsQuery.data?.last_24h || 0}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="Месяц"
|
||||
value={statsQuery.data?.last_month || 0}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="Всего"
|
||||
value={statsQuery.data?.total || 0}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Форма настроек */}
|
||||
<Form form={form} layout="vertical">
|
||||
<Card
|
||||
size="small"
|
||||
title="Основные параметры"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Form.Item
|
||||
label="Склад по умолчанию"
|
||||
name="default_store_id"
|
||||
tooltip="Этот склад будет выбираться автоматически при создании новой накладной"
|
||||
>
|
||||
<Select
|
||||
placeholder="Не выбрано"
|
||||
allowClear
|
||||
loading={dictQuery.isLoading}
|
||||
options={dictQuery.data?.stores.map((s) => ({
|
||||
label: s.name,
|
||||
value: s.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Корневая группа товаров"
|
||||
name="root_group_id"
|
||||
tooltip="Товары для распознавания будут искаться только внутри этой группы (и её подгрупп)."
|
||||
>
|
||||
<TreeSelect
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
|
||||
placeholder="Выберите папку"
|
||||
allowClear
|
||||
treeDefaultExpandAll={false}
|
||||
treeData={groupsQuery.data}
|
||||
// ИСПРАВЛЕНО: Маппинг полей под структуру JSON (title, value)
|
||||
fieldNames={{
|
||||
label: "title",
|
||||
value: "value",
|
||||
children: "children",
|
||||
}}
|
||||
treeNodeFilterProp="title"
|
||||
suffixIcon={<FolderOpenOutlined />}
|
||||
loading={groupsQuery.isLoading}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="auto_conduct"
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text>Проводить накладные автоматически</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Если выключено, накладные в iiko будут создаваться как
|
||||
"Непроведенные"
|
||||
</Text>
|
||||
</div>
|
||||
<Switch />
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
block
|
||||
size="large"
|
||||
onClick={handleSave}
|
||||
loading={saveMutation.isPending}
|
||||
>
|
||||
Сохранить настройки
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,9 +8,13 @@ import type {
|
||||
ProductMatch,
|
||||
Recommendation,
|
||||
UnmatchedItem,
|
||||
UserSettings,
|
||||
InvoiceStats,
|
||||
ProductGroup,
|
||||
Store,
|
||||
Supplier,
|
||||
DraftInvoice,
|
||||
DraftItem,
|
||||
UpdateDraftItemRequest,
|
||||
CommitDraftRequest,
|
||||
ProductSearchResult,
|
||||
@@ -151,6 +155,15 @@ export const api = {
|
||||
return data;
|
||||
},
|
||||
|
||||
addDraftItem: async (draftId: string): Promise<DraftItem> => {
|
||||
const { data } = await apiClient.post<DraftItem>(`/drafts/${draftId}/items`, {});
|
||||
return data;
|
||||
},
|
||||
|
||||
deleteDraftItem: async (draftId: string, itemId: string): Promise<void> => {
|
||||
await apiClient.delete(`/drafts/${draftId}/items/${itemId}`);
|
||||
},
|
||||
|
||||
commitDraft: async (draftId: string, payload: CommitDraftRequest): Promise<{ document_number: string }> => {
|
||||
const { data } = await apiClient.post<{ document_number: string }>(`/drafts/${draftId}/commit`, payload);
|
||||
return data;
|
||||
@@ -159,4 +172,26 @@ export const api = {
|
||||
deleteDraft: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/drafts/${id}`);
|
||||
},
|
||||
|
||||
// --- Настройки и Статистика ---
|
||||
|
||||
getSettings: async (): Promise<UserSettings> => {
|
||||
const { data } = await apiClient.get<UserSettings>('/settings');
|
||||
return data;
|
||||
},
|
||||
|
||||
updateSettings: async (payload: UserSettings): Promise<UserSettings> => {
|
||||
const { data } = await apiClient.post<UserSettings>('/settings', payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
getStats: async (): Promise<InvoiceStats> => {
|
||||
const { data } = await apiClient.get<InvoiceStats>('/stats/invoices');
|
||||
return data;
|
||||
},
|
||||
|
||||
getProductGroups: async (): Promise<ProductGroup[]> => {
|
||||
const { data } = await apiClient.get<ProductGroup[]>('/dictionaries/groups');
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -124,6 +124,29 @@ export interface Supplier {
|
||||
export interface DictionariesResponse {
|
||||
stores: Store[];
|
||||
suppliers: Supplier[];
|
||||
// product_groups?: ProductGroup[]; // пока не реализовано
|
||||
}
|
||||
|
||||
// --- Настройки и Статистика ---
|
||||
|
||||
export interface UserSettings {
|
||||
root_group_id: UUID | null;
|
||||
default_store_id: UUID | null;
|
||||
auto_conduct: boolean;
|
||||
}
|
||||
|
||||
export interface InvoiceStats {
|
||||
last_month: number;
|
||||
last_24h: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Интерфейс группы товаров (рекурсивный)
|
||||
export interface ProductGroup {
|
||||
key: string;
|
||||
value: string;
|
||||
title: string;
|
||||
children?: ProductGroup[];
|
||||
}
|
||||
|
||||
// --- Черновик Накладной (Draft) ---
|
||||
|
||||
Reference in New Issue
Block a user