Настройки работают

Иерархия групп работает
Полностью завязано на пользователя и серверы
This commit is contained in:
2025-12-18 07:21:31 +03:00
parent 542beafe0e
commit 4e4571b3db
23 changed files with 1572 additions and 385 deletions

View File

@@ -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;

View File

@@ -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
)}
</>
);
};
};

View File

@@ -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>
);
};
};

View File

@@ -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>
);
};
};

View 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>
);
};

View File

@@ -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;
},
};

View File

@@ -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) ---