2901-zustend для стора. сохранение черновиков построчно

редактор xml пока не работает, но есть
ui переработал
This commit is contained in:
2026-01-29 10:58:58 +03:00
parent b99e328d35
commit 4da5fdd130
23 changed files with 2391 additions and 1384 deletions

View File

@@ -132,12 +132,14 @@ const AppContent = () => {
<Route index element={<Navigate to="/invoices" replace />} />
<Route path="ocr" element={<OcrLearning />} />
<Route path="invoices" element={<DraftsList />} />
<Route path="invoice/draft/:id" element={<InvoiceDraftPage />} />
<Route path="invoice/view/:id" element={<InvoiceViewPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
{/* Роуты для детальных страниц накладных (без AppLayout - на весь экран) */}
<Route path="/invoice/draft/:id" element={<InvoiceDraftPage />} />
<Route path="/invoice/view/:id" element={<InvoiceViewPage />} />
{/* Десктопные роуты */}
<Route path="/web" element={<DesktopAuthScreen />} />
<Route path="/web" element={<DesktopLayout />}>

View File

@@ -0,0 +1,177 @@
import React, { useState, useEffect } from "react";
import { Modal, Button, message } from "antd";
import {
ZoomInOutlined,
ZoomOutOutlined,
UndoOutlined,
} from "@ant-design/icons";
import * as XLSX from "xlsx";
interface ExcelPreviewModalProps {
visible: boolean;
onCancel: () => void;
fileUrl: string;
}
/**
* Компонент для предпросмотра Excel файлов
* Позволяет просматривать содержимое Excel файлов с возможностью масштабирования
*/
const ExcelPreviewModal: React.FC<ExcelPreviewModalProps> = ({
visible,
onCancel,
fileUrl,
}) => {
// Данные таблицы из Excel файла
const [data, setData] = useState<
(string | number | boolean | null | undefined)[][]
>([]);
// Масштаб отображения таблицы
const [scale, setScale] = useState<number>(1);
/**
* Загрузка и парсинг Excel файла при изменении видимости или URL файла
*/
useEffect(() => {
const loadExcelFile = async () => {
if (!visible || !fileUrl) {
return;
}
try {
// Загрузка файла как arrayBuffer
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Ошибка загрузки файла: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
// Чтение Excel файла
const workbook = XLSX.read(arrayBuffer, { type: "array" });
// Получение первого листа
const firstSheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[firstSheetName];
// Преобразование листа в JSON-массив массивов
const jsonData = XLSX.utils.sheet_to_json(sheet, {
header: 1,
}) as (string | number | boolean | null | undefined)[][];
setData(jsonData);
// Сброс масштаба при загрузке нового файла
setScale(1);
} catch (error) {
console.error("Ошибка при загрузке Excel файла:", error);
message.error("Не удалось загрузить Excel файл");
setData([]);
}
};
loadExcelFile();
}, [visible, fileUrl]);
/**
* Увеличение масштаба
*/
const handleZoomIn = () => {
setScale((prev) => Math.min(prev + 0.1, 3));
};
/**
* Уменьшение масштаба
*/
const handleZoomOut = () => {
setScale((prev) => Math.max(prev - 0.1, 0.5));
};
/**
* Сброс масштаба до исходного значения
*/
const handleReset = () => {
setScale(1);
};
return (
<Modal
visible={visible}
onCancel={onCancel}
width="90%"
footer={null}
title="Предпросмотр Excel"
style={{ top: 20, zIndex: 10000 }}
>
{/* Панель инструментов для управления масштабом */}
<div style={{ marginBottom: 16, display: "flex", gap: 8 }}>
<Button
icon={<ZoomInOutlined />}
onClick={handleZoomIn}
disabled={scale >= 3}
>
Увеличить (+)
</Button>
<Button
icon={<ZoomOutOutlined />}
onClick={handleZoomOut}
disabled={scale <= 0.5}
>
Уменьшить (-)
</Button>
<Button
icon={<UndoOutlined />}
onClick={handleReset}
disabled={scale === 1}
>
Сброс
</Button>
<span style={{ marginLeft: "auto", lineHeight: "32px" }}>
Масштаб: {Math.round(scale * 100)}%
</span>
</div>
{/* Контейнер с прокруткой для таблицы */}
<div
style={{
height: 600,
overflow: "auto",
border: "1px solid #d9d9d9",
borderRadius: 4,
}}
>
{/* Таблица с данными Excel */}
<table
style={{
borderCollapse: "collapse",
transform: `scale(${scale})`,
transformOrigin: "top left",
width: "100%",
}}
>
<tbody>
{data.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<td
key={cellIndex}
style={{
border: "1px solid #ccc",
padding: "8px",
minWidth: 100,
whiteSpace: "nowrap",
}}
>
{cell !== undefined && cell !== null ? String(cell) : ""}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Modal>
);
};
export default ExcelPreviewModal;

View File

@@ -0,0 +1,762 @@
import React, { useEffect, useMemo, useState, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Spin,
Alert,
Button,
Form,
Select,
DatePicker,
Input,
Typography,
message,
Row,
Col,
Modal,
Tag,
Image,
} from "antd";
import {
CheckOutlined,
DeleteOutlined,
ExclamationCircleFilled,
StopOutlined,
ArrowLeftOutlined,
PlusOutlined,
FileImageOutlined,
FileExcelOutlined,
SwapOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import { api, getStaticUrl } from "../../services/api";
import { DraftItemRow } from "./DraftItemRow";
import ExcelPreviewModal from "../common/ExcelPreviewModal";
import { useActiveDraftStore } from "../../stores/activeDraftStore";
import type {
DraftItem,
CommitDraftRequest,
ReorderDraftItemsRequest,
} from "../../services/types";
import { DragDropContext, Droppable, type DropResult } from "@hello-pangea/dnd";
const { Text } = Typography;
const { TextArea } = Input;
const { confirm } = Modal;
interface DraftEditorProps {
draftId: string;
onBack?: () => void;
}
export const DraftEditor: React.FC<DraftEditorProps> = ({
draftId,
onBack,
}) => {
const queryClient = useQueryClient();
const [form] = Form.useForm();
// Zustand стор для локального управления элементами черновика
const {
items,
isDirty,
setItems,
updateItem,
deleteItem,
addItem,
reorderItems,
resetDirty,
} = useActiveDraftStore();
// Отслеживаем текущий draftId для инициализации стора
const currentDraftIdRef = useRef<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isReordering, setIsReordering] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Состояние для просмотра фото чека
const [previewVisible, setPreviewVisible] = useState(false);
// Состояние для просмотра Excel файла
const [excelPreviewVisible, setExcelPreviewVisible] = useState(false);
// --- ЗАПРОСЫ ---
const dictQuery = useQuery({
queryKey: ["dictionaries"],
queryFn: api.getDictionaries,
staleTime: 1000 * 60 * 5,
});
const recommendationsQuery = useQuery({
queryKey: ["recommendations"],
queryFn: api.getRecommendations,
});
const draftQuery = useQuery({
queryKey: ["draft", draftId],
queryFn: () => api.getDraft(draftId),
enabled: !!draftId,
refetchInterval: (query) => {
if (isDragging) return false;
const status = query.state.data?.status;
return status === "PROCESSING" ? 3000 : false;
},
});
const draft = draftQuery.data;
const stores = dictQuery.data?.stores || [];
const suppliers = dictQuery.data?.suppliers || [];
// Определение типа файла по расширению
const isExcelFile = draft?.photo_url?.toLowerCase().match(/\.(xls|xlsx)$/);
// --- МУТАЦИИ ---
const addItemMutation = useMutation({
mutationFn: () => api.addDraftItem(draftId),
onSuccess: (newItem) => {
message.success("Строка добавлена");
// Добавляем новый элемент в стор
addItem(newItem);
},
onError: () => {
message.error("Ошибка создания строки");
},
});
const commitMutation = useMutation({
mutationFn: (payload: CommitDraftRequest) =>
api.commitDraft(draftId, payload),
onSuccess: (data) => {
message.success(`Накладная ${data.document_number} создана!`);
queryClient.invalidateQueries({ queryKey: ["drafts"] });
},
onError: () => {
message.error("Ошибка при создании накладной");
},
});
const deleteDraftMutation = useMutation({
mutationFn: () => api.deleteDraft(draftId),
onSuccess: () => {
if (draft?.status === "CANCELED") {
message.info("Черновик удален окончательно");
} else {
message.warning("Черновик отменен");
queryClient.invalidateQueries({ queryKey: ["draft", draftId] });
}
},
onError: () => {
message.error("Ошибка при удалении");
},
});
const reorderItemsMutation = useMutation({
mutationFn: ({
draftId: id,
payload,
}: {
draftId: string;
payload: ReorderDraftItemsRequest;
}) => api.reorderDraftItems(id, payload),
onError: (error) => {
message.error("Не удалось изменить порядок элементов");
console.error("Reorder error:", error);
},
});
// --- ЭФФЕКТЫ ---
// Инициализация стора при загрузке черновика
useEffect(() => {
if (draft && draft.items) {
// Инициализируем стор только если изменился draftId или стор пуст
if (currentDraftIdRef.current !== draft.id || items.length === 0) {
setItems(draft.items);
currentDraftIdRef.current = draft.id;
}
}
}, [draft, items.length, setItems]);
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.incoming_document_number &&
draft.incoming_document_number
)
form.setFieldValue(
"incoming_document_number",
draft.incoming_document_number
);
if (!currentValues.date_incoming)
form.setFieldValue(
"date_incoming",
draft.date_incoming ? dayjs(draft.date_incoming) : dayjs()
);
}
}, [draft, form]);
// --- ХЕЛПЕРЫ ---
const totalSum = useMemo(() => {
return (
items.reduce(
(acc, item) => acc + Number(item.quantity) * Number(item.price),
0
) || 0
);
}, [items]);
const invalidItemsCount = useMemo(() => {
return items.filter((i) => !i.product_id).length || 0;
}, [items]);
// Функция сохранения изменений на сервер
const saveChanges = async () => {
if (!isDirty) return;
setIsSaving(true);
try {
// Собираем значения формы для обновления шапки черновика
const formValues = form.getFieldsValue();
// Подготавливаем payload для обновления мета-данных черновика
const draftPayload: Partial<CommitDraftRequest> = {
store_id: formValues.store_id,
supplier_id: formValues.supplier_id,
comment: formValues.comment || "",
incoming_document_number: formValues.incoming_document_number || "",
date_incoming: formValues.date_incoming
? formValues.date_incoming.format("YYYY-MM-DD")
: undefined,
};
// Сохраняем все измененные элементы
const savePromises = items.map((item) =>
api.updateDraftItem(draftId, item.id, {
product_id: item.product_id ?? null,
container_id: item.container_id ?? null,
quantity: Number(item.quantity),
price: Number(item.price),
sum: Number(item.sum),
})
);
// Параллельно сохраняем шапку и строки
await Promise.all([
api.updateDraft(draftId, draftPayload),
...savePromises,
]);
// После успешного сохранения обновляем данные с сервера
await queryClient.invalidateQueries({ queryKey: ["draft", draftId] });
// Сбрасываем флаг isDirty
resetDirty();
message.success("Изменения сохранены");
} catch (error) {
console.error("Ошибка сохранения:", error);
message.error("Не удалось сохранить изменения");
throw error;
} finally {
setIsSaving(false);
}
};
const handleItemUpdate = (id: string, changes: Partial<DraftItem>) => {
// Обновляем локально через стор
updateItem(id, changes);
};
const handleCommit = async () => {
try {
const values = await form.validateFields();
if (invalidItemsCount > 0) {
message.warning(
`Осталось ${invalidItemsCount} нераспознанных товаров!`
);
return;
}
// Сначала сохраняем изменения, если есть
if (isDirty) {
await saveChanges();
}
commitMutation.mutate({
date_incoming: values.date_incoming.format("YYYY-MM-DD"),
store_id: values.store_id,
supplier_id: values.supplier_id,
comment: values.comment || "",
incoming_document_number: values.incoming_document_number || "",
});
} catch {
message.error("Заполните обязательные поля (Склад, Поставщик)");
}
};
const isCanceled = draft?.status === "CANCELED";
const handleBack = () => {
if (isDirty) {
confirm({
title: "Сохранить изменения?",
icon: <ExclamationCircleFilled style={{ color: "#1890ff" }} />,
content:
"У вас есть несохраненные изменения. Сохранить их перед выходом?",
okText: "Сохранить и выйти",
cancelText: "Выйти без сохранения",
onOk: async () => {
try {
await saveChanges();
onBack?.();
} catch {
// Ошибка уже обработана в saveChanges
}
},
onCancel: () => {
onBack?.();
},
});
} else {
onBack?.();
}
};
const handleDelete = () => {
confirm({
title: isCanceled ? "Удалить окончательно?" : "Отменить черновик?",
icon: <ExclamationCircleFilled style={{ color: "red" }} />,
content: isCanceled
? "Черновик пропадет из списка навсегда."
: 'Черновик получит статус "Отменен", но останется в списке.',
okText: isCanceled ? "Удалить навсегда" : "Отменить",
okType: "danger",
cancelText: "Назад",
onOk() {
deleteDraftMutation.mutate();
},
});
};
const handleDragStart = () => {
setIsDragging(true);
};
const handleDragEnd = async (result: DropResult) => {
setIsDragging(false);
const { source, destination } = result;
// Если нет назначения или позиция не изменилась
if (
!destination ||
(source.droppableId === destination.droppableId &&
source.index === destination.index)
) {
return;
}
if (!draft) return;
// Обновляем локальное состояние через стор
reorderItems(source.index, destination.index);
// Подготавливаем payload для API
const reorderPayload: ReorderDraftItemsRequest = {
items: items.map((item, index) => ({
id: item.id,
order: index,
})),
};
// Отправляем запрос на сервер
try {
await reorderItemsMutation.mutateAsync({
draftId: draft.id,
payload: reorderPayload,
});
} catch {
// При ошибке откатываем локальное состояние через стор
reorderItems(destination.index, source.index);
message.error("Не удалось изменить порядок элементов");
}
};
// --- RENDER ---
const showSpinner =
draftQuery.isLoading ||
(draft?.status === "PROCESSING" && items.length === 0);
if (showSpinner) {
return (
<div style={{ textAlign: "center", padding: 50 }}>
<Spin size="large" />
</div>
);
}
if (draftQuery.isError || !draft) {
return <Alert type="error" message="Ошибка загрузки черновика" />;
}
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif",
backgroundColor: "#f5f5f5",
}}
>
{/* Единый хедер */}
<div
style={{
padding: "8px 12px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
flexShrink: 0,
background: "#fff",
borderBottom: "1px solid #f0f0f0",
}}
>
{/* Левая часть: Кнопка назад + Номер + Статус */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flex: 1,
minWidth: 0,
}}
>
{onBack && (
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={handleBack}
size="small"
style={{ flexShrink: 0 }}
/>
)}
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
flexWrap: "wrap",
minWidth: 0,
}}
>
<Text
style={{ fontSize: 16, fontWeight: "bold", whiteSpace: "nowrap" }}
>
{draft.document_number ? `${draft.document_number}` : "Черновик"}
</Text>
{draft.status === "PROCESSING" && <Spin size="small" />}
{isCanceled && (
<Tag color="red" style={{ margin: 0, fontSize: 11 }}>
ОТМЕНЕН
</Tag>
)}
</div>
</div>
{/* Правая часть: Кнопки действий */}
<div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
{/* Кнопка сохранения (показывается только если есть несохраненные изменения) */}
{isDirty && (
<Button
type="primary"
onClick={saveChanges}
loading={isSaving}
size="small"
>
Сохранить
</Button>
)}
{/* Кнопка просмотра чека (только если есть URL) */}
{draft.photo_url && (
<Button
icon={isExcelFile ? <FileExcelOutlined /> : <FileImageOutlined />}
onClick={() =>
isExcelFile
? setExcelPreviewVisible(true)
: setPreviewVisible(true)
}
size="small"
type="text"
/>
)}
{/* Кнопка переключения режима перетаскивания */}
<Button
type={isReordering ? "primary" : "default"}
icon={<SwapOutlined rotate={90} />}
onClick={() => setIsReordering(!isReordering)}
size="small"
/>
{/* Кнопка удаления/отмены */}
<Button
danger={isCanceled}
type={isCanceled ? "primary" : "default"}
icon={isCanceled ? <DeleteOutlined /> : <StopOutlined />}
onClick={handleDelete}
loading={deleteDraftMutation.isPending}
size="small"
/>
</div>
</div>
{/* Основная часть с прокруткой */}
<div style={{ flex: 1, overflowY: "auto", padding: "8px 12px" }}>
{/* Form: Склады и Поставщики */}
<div
style={{
background: "#fff",
padding: 8,
borderRadius: 8,
marginBottom: 12,
opacity: isCanceled ? 0.6 : 1,
}}
>
<Form
form={form}
layout="vertical"
initialValues={{ date_incoming: dayjs() }}
>
<Row gutter={[8, 8]}>
<Col span={12}>
<Form.Item
name="date_incoming"
rules={[{ required: true }]}
style={{ marginBottom: 0 }}
>
<DatePicker
style={{ width: "100%" }}
format="DD.MM.YYYY"
placeholder="Дата..."
size="small"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="incoming_document_number"
style={{ marginBottom: 0 }}
>
<Input placeholder="№ входящего..." size="small" />
</Form.Item>
</Col>
</Row>
<Row gutter={[8, 8]}>
<Col span={24}>
<Form.Item
name="store_id"
rules={[{ required: true, message: "Выберите склад" }]}
style={{ marginBottom: 0 }}
>
<Select
placeholder="Выберите склад..."
loading={dictQuery.isLoading}
options={stores.map((s) => ({
label: s.name,
value: s.id,
}))}
size="small"
/>
</Form.Item>
</Col>
</Row>
<Form.Item
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="small"
showSearch
filterOption={(input, option) =>
(option?.label ?? "")
.toLowerCase()
.includes(input.toLowerCase())
}
/>
</Form.Item>
<Form.Item name="comment" style={{ marginBottom: 0 }}>
<TextArea
rows={1}
placeholder="Комментарий..."
style={{ fontSize: 13 }}
size="small"
/>
</Form.Item>
</Form>
</div>
{/* Items Header */}
<div
style={{
marginBottom: 8,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0 4px",
}}
>
<Text strong style={{ fontSize: 14 }}>
Позиции ({items.length})
</Text>
{invalidItemsCount > 0 && (
<Text type="danger" style={{ fontSize: 12 }}>
{invalidItemsCount} нераспознано
</Text>
)}
</div>
{/* Items List */}
<DragDropContext
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<Droppable droppableId="draft-items">
{(provided, snapshot) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
style={{
display: "flex",
flexDirection: "column",
gap: 8,
backgroundColor: snapshot.isDraggingOver
? "#f0f0f0"
: "transparent",
borderRadius: "4px",
padding: snapshot.isDraggingOver ? "8px" : "0",
transition: "background-color 0.2s ease",
}}
>
{items.map((item, index) => (
<DraftItemRow
key={item.id}
item={item}
index={index}
onLocalUpdate={handleItemUpdate}
onDelete={(itemId) => deleteItem(itemId)}
isUpdating={false}
recommendations={recommendationsQuery.data || []}
isReordering={isReordering}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{/* Кнопка добавления позиции */}
<Button
type="dashed"
block
icon={<PlusOutlined />}
style={{ marginTop: 12, marginBottom: 80, height: 40 }}
onClick={() => addItemMutation.mutate()}
loading={addItemMutation.isPending}
disabled={isCanceled}
size="small"
>
Добавить товар
</Button>
</div>
{/* Footer Actions - прижат к низу контейнера */}
<div
style={{
flexShrink: 0,
background: "#fff",
padding: "8px 12px",
borderTop: "1px solid #f0f0f0",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div style={{ display: "flex", flexDirection: "column" }}>
<Text style={{ fontSize: 11, color: "#888", lineHeight: 1 }}>
Итого:
</Text>
<Text
style={{
fontSize: 16,
fontWeight: "bold",
color: "#1890ff",
lineHeight: 1.2,
}}
>
{totalSum.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</Text>
</div>
<Button
type="primary"
icon={<CheckOutlined />}
onClick={handleCommit}
loading={commitMutation.isPending}
disabled={invalidItemsCount > 0 || isCanceled}
style={{ height: 36, padding: "0 20px" }}
size="small"
>
{isCanceled ? "Восстановить" : "Отправить"}
</Button>
</div>
{/* Скрытый компонент для просмотра изображения */}
{draft.photo_url && (
<div style={{ display: "none" }}>
<Image.PreviewGroup
preview={{
visible: previewVisible,
onVisibleChange: (vis) => setPreviewVisible(vis),
movable: true,
scaleStep: 0.5,
}}
>
<Image src={getStaticUrl(draft.photo_url)} />
</Image.PreviewGroup>
</div>
)}
{/* Модальное окно для просмотра Excel файлов */}
<ExcelPreviewModal
visible={excelPreviewVisible}
onCancel={() => setExcelPreviewVisible(false)}
fileUrl={draft.photo_url ? getStaticUrl(draft.photo_url) : ""}
/>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useState, useEffect, useRef } from "react";
import React, { useMemo, useState } from "react";
import { Draggable } from "@hello-pangea/dnd";
import {
Card,
@@ -23,7 +23,6 @@ import { CatalogSelect } from "../ocr/CatalogSelect";
import { CreateContainerModal } from "./CreateContainerModal";
import type {
DraftItem,
UpdateDraftItemRequest,
ProductSearchResult,
ProductContainer,
Recommendation,
@@ -31,22 +30,20 @@ import type {
const { Text } = Typography;
interface Props {
interface DraftItemRowProps {
item: DraftItem;
index: number;
onUpdate: (itemId: string, changes: UpdateDraftItemRequest) => void;
onLocalUpdate: (id: string, changes: Partial<DraftItem>) => void;
onDelete: (itemId: string) => void;
isUpdating: boolean;
recommendations?: Recommendation[];
isReordering: boolean;
}
type FieldType = "quantity" | "price" | "sum";
export const DraftItemRow: React.FC<Props> = ({
export const DraftItemRow: React.FC<DraftItemRowProps> = ({
item,
index,
onUpdate,
onLocalUpdate,
onDelete,
isUpdating,
recommendations = [],
@@ -54,151 +51,14 @@ export const DraftItemRow: React.FC<Props> = ({
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
// --- Локальное состояние значений (строки для удобства ввода) ---
const [localQty, setLocalQty] = useState<number | null>(item.quantity);
const [localPrice, setLocalPrice] = useState<number | null>(item.price);
const [localSum, setLocalSum] = useState<number | null>(item.sum);
// --- История редактирования (Stack) ---
// Храним 2 последних отредактированных поля.
// Инициализируем из пропсов или дефолтно ['quantity', 'price'], чтобы пересчитывалась сумма.
const editStack = useRef<FieldType[]>([
(item.last_edited_field_1 as FieldType) || "quantity",
(item.last_edited_field_2 as FieldType) || "price",
]);
// Храним ссылку на предыдущую версию item, чтобы сравнивать изменения
// --- Синхронизация с сервером ---
useEffect(() => {
// Если мы ждем ответа от сервера, не сбиваем локальный ввод
if (isUpdating) return;
// Обновляем локальные стейты только когда меняются конкретные поля в item
setLocalQty(item.quantity);
setLocalPrice(item.price);
setLocalSum(item.sum);
// Обновляем стек редактирования
if (item.last_edited_field_1 && item.last_edited_field_2) {
editStack.current = [
item.last_edited_field_1 as FieldType,
item.last_edited_field_2 as FieldType,
];
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
// Зависим ТОЛЬКО от примитивов. Если объект item изменится, но цифры те же - эффект не сработает.
item.quantity,
item.price,
item.sum,
item.last_edited_field_1,
item.last_edited_field_2,
isUpdating,
]);
// --- Логика пересчета (Треугольник) ---
const recalculateLocally = (changedField: FieldType, newVal: number) => {
// 1. Обновляем стек истории
// Удаляем поле, если оно уже было в стеке, и добавляем в начало (LIFO для важности)
const currentStack = editStack.current.filter((f) => f !== changedField);
currentStack.unshift(changedField);
// Оставляем только 2 последних
if (currentStack.length > 2) currentStack.pop();
editStack.current = currentStack;
// 2. Определяем, какое поле нужно пересчитать (то, которого НЕТ в стеке)
const allFields: FieldType[] = ["quantity", "price", "sum"];
const fieldToRecalc = allFields.find((f) => !currentStack.includes(f));
// 3. Выполняем расчет
let q = changedField === "quantity" ? newVal : localQty || 0;
let p = changedField === "price" ? newVal : localPrice || 0;
let s = changedField === "sum" ? newVal : localSum || 0;
switch (fieldToRecalc) {
case "sum":
s = q * p;
setLocalSum(s);
break;
case "quantity":
if (p !== 0) {
q = s / p;
setLocalQty(q);
} else {
setLocalQty(0);
}
break;
case "price":
if (q !== 0) {
p = s / q;
setLocalPrice(p);
} else {
setLocalPrice(0);
}
break;
}
};
// --- Обработчики ввода ---
const handleValueChange = (field: FieldType, val: number | null) => {
// Обновляем само поле
if (field === "quantity") setLocalQty(val);
if (field === "price") setLocalPrice(val);
if (field === "sum") setLocalSum(val);
if (val !== null) {
recalculateLocally(field, val);
}
};
const handleBlur = (field: FieldType) => {
// Отправляем на сервер только измененное поле + маркер edited_field.
// Сервер сам проведет пересчет и вернет точные данные.
// Важно: отправляем текущее локальное значение.
let val: number | null = null;
if (field === "quantity") val = localQty;
if (field === "price") val = localPrice;
if (field === "sum") val = localSum;
if (val === null) return;
// Сравниваем с текущим item, чтобы не спамить запросами, если число не поменялось
const serverVal = item[field];
// Используем эпсилон для сравнения float
if (Math.abs(val - serverVal) > 0.0001) {
onUpdate(item.id, {
[field]: val,
edited_field: field,
});
}
};
// --- Product & Container Logic (как было) ---
const [searchedProduct, setSearchedProduct] =
useState<ProductSearchResult | null>(null);
const [addedContainers, setAddedContainers] = useState<
Record<string, ProductContainer[]>
>({});
const activeProduct = useMemo(() => {
if (searchedProduct && searchedProduct.id === item.product_id)
return searchedProduct;
return item.product as unknown as ProductSearchResult | undefined;
}, [searchedProduct, item.product, item.product_id]);
}, [item.product]);
const containers = useMemo(() => {
if (!activeProduct) return [];
const baseContainers = activeProduct.containers || [];
const manuallyAdded = addedContainers[activeProduct.id] || [];
const combined = [...baseContainers];
manuallyAdded.forEach((c) => {
if (!combined.find((existing) => existing.id === c.id)) combined.push(c);
});
return combined;
}, [activeProduct, addedContainers]);
return activeProduct.containers || [];
}, [activeProduct]);
const baseUom =
activeProduct?.main_unit?.name || activeProduct?.measure_unit || "ед.";
@@ -255,34 +115,41 @@ export const DraftItemRow: React.FC<Props> = ({
// --- Handlers ---
const handleProductChange = (
prodId: string,
prodId: string | null,
productObj?: ProductSearchResult
) => {
if (productObj) setSearchedProduct(productObj);
onUpdate(item.id, {
onLocalUpdate(item.id, {
product_id: prodId,
container_id: null, // Сбрасываем фасовку
// При смене товара логично оставить Qty и Sum, пересчитав Price?
// Или оставить Qty и Price? Обычно цена меняется.
// Пока не трогаем числа, пусть остаются как были.
product: prodId ? productObj : null,
container_id: null,
});
};
const handleContainerChange = (val: string) => {
// "" пустая строка приходит при выборе "Базовая" (мы так настроим value)
const newVal = val === "BASE_UNIT" ? "" : val;
onUpdate(item.id, { container_id: newVal });
onLocalUpdate(item.id, {
container_id: newVal,
});
};
const handleContainerCreated = (newContainer: ProductContainer) => {
setIsModalOpen(false);
if (activeProduct) {
setAddedContainers((prev) => ({
...prev,
[activeProduct.id]: [...(prev[activeProduct.id] || []), newContainer],
}));
onLocalUpdate(item.id, { container_id: newContainer.id });
};
const handleValueChange = (
field: "quantity" | "price" | "sum",
val: number | null
) => {
if (val !== null) {
onLocalUpdate(item.id, {
[field]: Number(val),
});
}
onUpdate(item.id, { container_id: newContainer.id });
};
const handleBlur = () => {
// Изменения уже отправлены через onLocalUpdate в handleValueChange
};
const cardBorderColor = !item.product_id
@@ -293,7 +160,11 @@ export const DraftItemRow: React.FC<Props> = ({
return (
<>
<Draggable draggableId={item.id} index={index} isDragDisabled={!isReordering}>
<Draggable
draggableId={item.id}
index={index}
isDragDisabled={!isReordering}
>
{(provided, snapshot) => {
const style = {
marginBottom: "8px",
@@ -420,7 +291,7 @@ export const DraftItemRow: React.FC<Props> = ({
</Flex>
<CatalogSelect
value={item.product_id || undefined}
value={item.product_id || null}
onChange={handleProductChange}
initialProduct={activeProduct}
/>
@@ -476,10 +347,13 @@ export const DraftItemRow: React.FC<Props> = ({
controls={false}
placeholder="Кол"
min={0}
value={localQty}
value={item.quantity}
onChange={(val) => handleValueChange("quantity", val)}
onBlur={() => handleBlur("quantity")}
onBlur={() => handleBlur()}
precision={3}
parser={(value) =>
value?.replace(",", ".") as unknown as number
}
/>
<Text type="secondary">x</Text>
<InputNumber
@@ -487,10 +361,13 @@ export const DraftItemRow: React.FC<Props> = ({
controls={false}
placeholder="Цена"
min={0}
value={localPrice}
value={item.price}
onChange={(val) => handleValueChange("price", val)}
onBlur={() => handleBlur("price")}
onBlur={() => handleBlur()}
precision={2}
parser={(value) =>
value?.replace(",", ".") as unknown as number
}
/>
</div>
@@ -503,10 +380,13 @@ export const DraftItemRow: React.FC<Props> = ({
controls={false}
placeholder="Сумма"
min={0}
value={localSum}
value={item.sum}
onChange={(val) => handleValueChange("sum", val)}
onBlur={() => handleBlur("sum")}
onBlur={() => handleBlur()}
precision={2}
parser={(value) =>
value?.replace(",", ".") as unknown as number
}
/>
</div>
</div>

View File

@@ -0,0 +1,217 @@
import React, { useState } from "react";
import { Modal, Spin, Button, Typography, Alert, message } from "antd";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { DragDropContext, Droppable } from "@hello-pangea/dnd";
import { api } from "../../services/api";
import { DraftItemRow } from "./DraftItemRow";
import type { UpdateDraftItemRequest, DraftItem } from "../../services/types";
const { Text } = Typography;
interface DraftVerificationModalProps {
draftId: string;
visible: boolean;
onClose: () => void;
}
/**
* Модальное окно для быстрой проверки черновика после загрузки файла
* Позволяет просмотреть и отредактировать позиции перед переходом к полному редактированию
*/
export const DraftVerificationModal: React.FC<DraftVerificationModalProps> = ({
draftId,
visible,
onClose,
}) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
// Получаем данные черновика
const draftQuery = useQuery({
queryKey: ["draft", draftId],
queryFn: () => api.getDraft(draftId),
enabled: visible && !!draftId,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "PROCESSING" ? 3000 : false;
},
});
// Получаем рекомендации
const recommendationsQuery = useQuery({
queryKey: ["recommendations"],
queryFn: api.getRecommendations,
enabled: visible,
});
// Мутация для обновления строки
const updateItemMutation = useMutation({
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
api.updateDraftItem(draftId, vars.itemId, vars.payload),
onMutate: async ({ itemId }) => {
setUpdatingItems((prev) => new Set(prev).add(itemId));
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["draft", draftId] });
},
onError: () => {
message.error("Не удалось сохранить строку");
},
onSettled: (_data, _err, vars) => {
setUpdatingItems((prev) => {
const next = new Set(prev);
next.delete(vars.itemId);
return next;
});
},
});
const draft = draftQuery.data;
const recommendations = recommendationsQuery.data || [];
const handleItemUpdate = (itemId: string, changes: Partial<DraftItem>) => {
// Преобразуем Partial<DraftItem> в UpdateDraftItemRequest
const payload: UpdateDraftItemRequest = {};
if (changes.product_id !== undefined) {
// product_id может быть null, но UpdateDraftItemRequest ожидает только UUID
// Если null, не включаем поле в payload
if (changes.product_id !== null) {
payload.product_id = changes.product_id;
}
}
if (changes.container_id !== undefined) {
payload.container_id = changes.container_id;
}
if (changes.quantity !== undefined) {
payload.quantity = changes.quantity;
}
if (changes.price !== undefined) {
payload.price = changes.price;
}
if (changes.sum !== undefined) {
payload.sum = changes.sum;
}
updateItemMutation.mutate({ itemId, payload });
};
const handleDeleteItem = () => {
// В модальном окне не реализуем удаление для упрощения
// Пользователь может перейти к полному редактированию
message.info("Для удаления позиции перейдите к полному редактированию");
};
const handleGoToFullEdit = () => {
onClose();
navigate(`/web/invoice/draft/${draftId}`);
};
const invalidItemsCount =
draft?.items.filter((i) => !i.product_id).length || 0;
return (
<Modal
title="Проверка черновика"
open={visible}
onCancel={onClose}
width={800}
footer={[
<Button key="close" onClick={onClose}>
Закрыть
</Button>,
<Button
key="edit"
type="primary"
onClick={handleGoToFullEdit}
disabled={draftQuery.isLoading}
>
Перейти к полному редактированию
</Button>,
]}
destroyOnClose
>
{draftQuery.isLoading ? (
<div style={{ textAlign: "center", padding: "40px 0" }}>
<Spin size="large" />
<div style={{ marginTop: "16px" }}>
<Text type="secondary">Обработка файла...</Text>
</div>
</div>
) : draftQuery.isError ? (
<Alert
message="Ошибка загрузки черновика"
description="Не удалось загрузить данные черновика. Попробуйте закрыть модальное окно и загрузить файл заново."
type="error"
showIcon
/>
) : draft ? (
<div>
{/* Информация о черновике */}
<div style={{ marginBottom: "16px" }}>
<Text strong>Номер: </Text>
<Text>{draft.document_number || "Не указан"}</Text>
<br />
<Text strong>Статус: </Text>
<Text>{draft.status}</Text>
{draft.photo_url && (
<>
<br />
<Text strong>Фото чека: </Text>
<Text type="secondary">Загружено</Text>
</>
)}
</div>
{/* Предупреждение о нераспознанных товарах */}
{invalidItemsCount > 0 && (
<Alert
message={`${invalidItemsCount} позиций не распознано`}
description="Пожалуйста, сопоставьте товары с каталогом перед отправкой"
type="warning"
showIcon
style={{ marginBottom: "16px" }}
/>
)}
{/* Список позиций */}
<div style={{ maxHeight: "400px", overflowY: "auto" }}>
{draft.items.length === 0 ? (
<div style={{ textAlign: "center", padding: "20px" }}>
<Text type="secondary">Нет позиций</Text>
</div>
) : (
<DragDropContext onDragEnd={() => {}}>
<Droppable droppableId="modal-verification-list">
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps}>
{draft.items.map((item, index) => (
<DraftItemRow
key={item.id}
item={item}
index={index}
onLocalUpdate={handleItemUpdate}
onDelete={handleDeleteItem}
isUpdating={updatingItems.has(item.id)}
recommendations={recommendations}
isReordering={false}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)}
</div>
</div>
) : null}
</Modal>
);
};

View File

@@ -0,0 +1,263 @@
import React, { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Spin, Alert, Button, Table, Typography, Tag, Image } from "antd";
import {
FileImageOutlined,
FileExcelOutlined,
HistoryOutlined,
RestOutlined,
} from "@ant-design/icons";
import { api, getStaticUrl } from "../../services/api";
import type { DraftStatus } from "../../services/types";
import ExcelPreviewModal from "../common/ExcelPreviewModal";
const { Title, Text } = Typography;
interface InvoiceViewerProps {
invoiceId: string;
onBack?: () => void;
}
export const InvoiceViewer: React.FC<InvoiceViewerProps> = ({
invoiceId,
onBack,
}) => {
// Состояние для просмотра фото чека
const [previewVisible, setPreviewVisible] = useState(false);
// Состояние для просмотра Excel файла
const [excelPreviewVisible, setExcelPreviewVisible] = useState(false);
// Запрос данных накладной
const {
data: invoice,
isLoading,
isError,
} = useQuery({
queryKey: ["invoice", invoiceId],
queryFn: () => api.getInvoice(invoiceId),
enabled: !!invoiceId,
});
// Определение типа файла по расширению
const isExcelFile = invoice?.photo_url?.toLowerCase().match(/\.(xls|xlsx)$/);
const getStatusTag = (status: DraftStatus) => {
switch (status) {
case "PROCESSING":
return <Tag color="blue">Обработка</Tag>;
case "READY_TO_VERIFY":
return <Tag color="orange">Проверка</Tag>;
case "COMPLETED":
return (
<Tag icon={<HistoryOutlined />} color="success">
Синхронизировано
</Tag>
);
case "ERROR":
return <Tag color="red">Ошибка</Tag>;
case "CANCELED":
return <Tag color="default">Отменен</Tag>;
default:
return <Tag>{status}</Tag>;
}
};
if (isLoading) {
return (
<div style={{ textAlign: "center", padding: 50 }}>
<Spin size="large" />
</div>
);
}
if (isError || !invoice) {
return <Alert type="error" message="Ошибка загрузки накладной" />;
}
const columns = [
{
title: "Товар",
dataIndex: "name",
key: "name",
},
{
title: "Кол-во",
dataIndex: "quantity",
key: "quantity",
align: "right" as const,
},
{
title: "Сумма",
dataIndex: "total",
key: "total",
align: "right" as const,
render: (total: number) =>
total.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
}),
},
];
const totalSum = (invoice.items || []).reduce(
(acc, item) => acc + item.total,
0
);
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#f5f5f5",
}}
>
{/* Единый хедер */}
<div
style={{
padding: "8px 12px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
flexShrink: 0,
background: "#fff",
borderBottom: "1px solid #f0f0f0",
}}
>
{/* Левая часть: Кнопка назад + Номер + Информация */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flex: 1,
minWidth: 0,
}}
>
{onBack && (
<Button
type="text"
icon={<RestOutlined />}
onClick={onBack}
size="small"
style={{ flexShrink: 0 }}
/>
)}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 2,
minWidth: 0,
}}
>
<Title
level={5}
style={{ margin: 0, fontSize: 16, whiteSpace: "nowrap" }}
>
{invoice.number}
</Title>
<Text
type="secondary"
style={{ fontSize: 11, whiteSpace: "nowrap" }}
>
{invoice.date} {invoice.supplier.name}
</Text>
</div>
</div>
{/* Правая часть: Статус + Кнопка фото */}
<div
style={{
display: "flex",
gap: 6,
alignItems: "center",
flexShrink: 0,
}}
>
{getStatusTag(invoice.status)}
{/* Кнопка просмотра чека (только если есть URL) */}
{invoice.photo_url && (
<Button
icon={isExcelFile ? <FileExcelOutlined /> : <FileImageOutlined />}
onClick={() =>
isExcelFile
? setExcelPreviewVisible(true)
: setPreviewVisible(true)
}
size="small"
type="text"
/>
)}
</div>
</div>
{/* Основная часть с прокруткой */}
<div style={{ flex: 1, overflowY: "auto", padding: "8px 12px" }}>
{/* Таблица товаров */}
<div
style={{
background: "#fff",
padding: 12,
borderRadius: 8,
marginBottom: 16,
}}
>
<Title level={5} style={{ marginBottom: 12, fontSize: 14 }}>
Товары ({(invoice.items || []).length} поз.)
</Title>
<Table
columns={columns}
dataSource={invoice.items || []}
pagination={false}
rowKey="name"
size="small"
summary={() => (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={2}>
<Text strong>Итого:</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} align="right">
<Text strong>
{totalSum.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
})}
</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
)}
/>
</div>
</div>
{/* Скрытый компонент для просмотра изображения */}
{invoice.photo_url && (
<div style={{ display: "none" }}>
<Image.PreviewGroup
preview={{
visible: previewVisible,
onVisibleChange: (vis) => setPreviewVisible(vis),
movable: true,
scaleStep: 0.5,
}}
>
<Image src={getStaticUrl(invoice.photo_url)} />
</Image.PreviewGroup>
</div>
)}
{/* Модальное окно для просмотра Excel файлов */}
<ExcelPreviewModal
visible={excelPreviewVisible}
onCancel={() => setExcelPreviewVisible(false)}
fileUrl={invoice.photo_url ? getStaticUrl(invoice.photo_url) : ""}
/>
</div>
);
};

View File

@@ -144,10 +144,10 @@ export const AddMatchForm: React.FC<Props> = ({
// --- Хендлеры ---
const handleProductChange = (
val: string,
val: string | null,
productObj?: ProductSearchResult
) => {
setSelectedProduct(val);
setSelectedProduct(val || undefined);
if (productObj) {
setSelectedProductData(productObj);
}
@@ -282,7 +282,7 @@ export const AddMatchForm: React.FC<Props> = ({
Товар в iiko:
</div>
<CatalogSelect
value={selectedProduct}
value={selectedProduct || null}
onChange={handleProductChange}
disabled={isLoading}
initialProduct={activeProduct} // Передаем полный объект для правильного отображения!

View File

@@ -4,8 +4,8 @@ import { api } from "../../services/api";
import type { CatalogItem, ProductSearchResult } from "../../services/types";
interface Props {
value?: string;
onChange?: (value: string, productObj?: ProductSearchResult) => void;
value: string | null;
onChange: (value: string | null, productObj?: ProductSearchResult) => void;
disabled?: boolean;
initialProduct?: CatalogItem | ProductSearchResult;
}
@@ -85,12 +85,12 @@ export const CatalogSelect: React.FC<Props> = ({
};
const handleChange = (
val: string,
val: string | undefined,
option: SelectOption | SelectOption[] | undefined
) => {
if (onChange) {
const opt = Array.isArray(option) ? option[0] : option;
onChange(val, opt?.data);
onChange(val ?? null, opt?.data);
}
};
@@ -108,7 +108,7 @@ export const CatalogSelect: React.FC<Props> = ({
) : null
}
options={options}
value={value}
value={value || undefined}
onChange={handleChange}
disabled={disabled}
style={{ width: "100%" }}
@@ -118,6 +118,7 @@ export const CatalogSelect: React.FC<Props> = ({
onClear={() => {
setOptions([]);
setNotFound(false);
onChange(null);
}}
// При клике (фокусе), если поле пустое - можно показать дефолтные опции или оставить пустым
onFocus={() => {

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { Layout, Space, Avatar, Dropdown, Button } from 'antd';
import { UserOutlined, LogoutOutlined } from '@ant-design/icons';
import { useAuthStore } from '../../stores/authStore';
import React, { useEffect } from "react";
import { Layout, Space, Avatar, Dropdown, Select } from "antd";
import { UserOutlined, LogoutOutlined } from "@ant-design/icons";
import { useAuthStore } from "../../stores/authStore";
import { useServerStore } from "../../stores/serverStore";
const { Header } = Layout;
@@ -11,16 +12,27 @@ const { Header } = Layout;
*/
export const DesktopHeader: React.FC = () => {
const { user, logout } = useAuthStore();
const { servers, activeServer, isLoading, fetchServers, setActiveServer } =
useServerStore();
// Загружаем список серверов при маунте компонента
useEffect(() => {
fetchServers();
}, [fetchServers]);
const handleLogout = () => {
logout();
window.location.href = '/web';
window.location.href = "/web";
};
const handleServerChange = (serverId: string) => {
setActiveServer(serverId);
};
const userMenuItems = [
{
key: 'logout',
label: 'Выйти',
key: "logout",
label: "Выйти",
icon: <LogoutOutlined />,
onClick: handleLogout,
},
@@ -29,55 +41,65 @@ export const DesktopHeader: React.FC = () => {
return (
<Header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#ffffff',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)',
padding: '0 24px',
height: '64px',
position: 'fixed',
display: "flex",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: "#ffffff",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.06)",
padding: "0 24px",
height: "64px",
position: "fixed",
top: 0,
left: 0,
right: 0,
zIndex: 1000,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
<div style={{ display: "flex", alignItems: "center", gap: "24px" }}>
{/* Логотип */}
<div
style={{
fontSize: '20px',
fontWeight: 'bold',
color: '#1890ff',
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: "20px",
fontWeight: "bold",
color: "#1890ff",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span>RMSer</span>
</div>
{/* Заглушка выбора сервера */}
<Button
type="default"
ghost
style={{
color: '#8c8c8c',
borderColor: '#d9d9d9',
cursor: 'default',
}}
>
Сервер не выбран
</Button>
{/* Выбор сервера */}
<Select
placeholder="Выберите сервер"
value={activeServer?.id || undefined}
onChange={handleServerChange}
loading={isLoading}
disabled={isLoading}
style={{ minWidth: "200px" }}
options={servers.map((server) => ({
label: server.name,
value: server.id,
}))}
/>
</div>
{/* Аватар пользователя */}
<Space>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<Avatar size="default" icon={<UserOutlined />} />
<span style={{ color: '#262626' }}>{user?.username || 'Пользователь'}</span>
<span style={{ color: "#262626" }}>
{user?.username || "Пользователь"}
</span>
</div>
</Dropdown>
</Space>

View File

@@ -1,651 +1,18 @@
import React, { useEffect, useMemo, useState } from "react";
import React 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,
Image,
} from "antd";
import {
ArrowLeftOutlined,
CheckOutlined,
DeleteOutlined,
ExclamationCircleFilled,
RestOutlined,
PlusOutlined,
FileImageOutlined,
SwapOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import { api, getStaticUrl } from "../services/api";
import { DraftItemRow } from "../components/invoices/DraftItemRow";
import type {
UpdateDraftItemRequest,
CommitDraftRequest,
ReorderDraftItemsRequest,
} from "../services/types";
import { DragDropContext, Droppable, type DropResult } from "@hello-pangea/dnd";
const { Text } = Typography;
const { TextArea } = Input;
const { confirm } = Modal;
import { DraftEditor } from "../components/invoices/DraftEditor";
export const InvoiceDraftPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { id: draftId } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [form] = Form.useForm();
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
const [itemsOrder, setItemsOrder] = useState<Record<string, number>>({});
const [isDragging, setIsDragging] = useState(false);
const [isReordering, setIsReordering] = useState(false);
// Состояние для просмотра фото чека
const [previewVisible, setPreviewVisible] = useState(false);
// --- ЗАПРОСЫ ---
const dictQuery = useQuery({
queryKey: ["dictionaries"],
queryFn: api.getDictionaries,
staleTime: 1000 * 60 * 5,
});
const recommendationsQuery = useQuery({
queryKey: ["recommendations"],
queryFn: api.getRecommendations,
});
const draftQuery = useQuery({
queryKey: ["draft", id],
queryFn: () => api.getDraft(id!),
enabled: !!id,
refetchInterval: (query) => {
if (isDragging) return false;
const status = query.state.data?.status;
return status === "PROCESSING" ? 3000 : false;
},
});
const draft = draftQuery.data;
const stores = dictQuery.data?.stores || [];
const suppliers = dictQuery.data?.suppliers || [];
// --- МУТАЦИИ ---
const updateItemMutation = useMutation({
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
api.updateDraftItem(id!, vars.itemId, vars.payload),
onMutate: async ({ itemId }) => {
setUpdatingItems((prev) => new Set(prev).add(itemId));
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["draft", id] });
},
onError: () => {
message.error("Не удалось сохранить строку");
},
onSettled: (_data, _err, vars) => {
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");
queryClient.invalidateQueries({ queryKey: ["drafts"] });
},
onError: () => {
message.error("Ошибка при создании накладной");
},
});
const deleteDraftMutation = useMutation({
mutationFn: () => api.deleteDraft(id!),
onSuccess: () => {
if (draft?.status === "CANCELED") {
message.info("Черновик удален окончательно");
navigate("/invoices");
} else {
message.warning("Черновик отменен");
queryClient.invalidateQueries({ queryKey: ["draft", id] });
}
},
onError: () => {
message.error("Ошибка при удалении");
},
});
const reorderItemsMutation = useMutation({
mutationFn: ({
draftId,
payload,
}: {
draftId: string;
payload: ReorderDraftItemsRequest;
}) => api.reorderDraftItems(draftId, payload),
onError: (error) => {
message.error("Не удалось изменить порядок элементов");
console.error("Reorder error:", 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.incoming_document_number &&
draft.incoming_document_number
)
form.setFieldValue(
"incoming_document_number",
draft.incoming_document_number
);
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;
}, [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} нераспознанных товаров!`
);
return;
}
commitMutation.mutate({
date_incoming: values.date_incoming.format("YYYY-MM-DD"),
store_id: values.store_id,
supplier_id: values.supplier_id,
comment: values.comment || "",
incoming_document_number: values.incoming_document_number || "",
});
} catch {
message.error("Заполните обязательные поля (Склад, Поставщик)");
}
};
const isCanceled = draft?.status === "CANCELED";
const handleDelete = () => {
confirm({
title: isCanceled ? "Удалить окончательно?" : "Отменить черновик?",
icon: <ExclamationCircleFilled style={{ color: "red" }} />,
content: isCanceled
? "Черновик пропадет из списка навсегда."
: 'Черновик получит статус "Отменен", но останется в списке.',
okText: isCanceled ? "Удалить навсегда" : "Отменить",
okType: "danger",
cancelText: "Назад",
onOk() {
deleteDraftMutation.mutate();
},
});
};
const handleDragStart = () => {
setIsDragging(true);
};
const handleDragEnd = async (result: DropResult) => {
setIsDragging(false);
const { source, destination } = result;
// Если нет назначения или позиция не изменилась
if (
!destination ||
(source.droppableId === destination.droppableId &&
source.index === destination.index)
) {
return;
}
if (!draft) return;
// Сохраняем предыдущее состояние для отката
const previousItems = [...draft.items];
const previousOrder = { ...itemsOrder };
// Создаём новый массив с изменённым порядком
const newItems = [...draft.items];
const [removed] = newItems.splice(source.index, 1);
newItems.splice(destination.index, 0, removed);
// Обновляем локальное состояние немедленно для быстрого UI
queryClient.setQueryData(["draft", id], {
...draft,
items: newItems,
});
// Подготавливаем payload для API
const reorderPayload: ReorderDraftItemsRequest = {
items: newItems.map((item, index) => ({
id: item.id,
order: index,
})),
};
// Отправляем запрос на сервер
try {
await reorderItemsMutation.mutateAsync({
draftId: draft.id,
payload: reorderPayload,
});
} catch {
// При ошибке откатываем локальное состояние
queryClient.setQueryData(["draft", id], {
...draft,
items: previousItems,
});
setItemsOrder(previousOrder);
}
};
// --- RENDER ---
const showSpinner =
draftQuery.isLoading ||
(draft?.status === "PROCESSING" &&
(!draft?.items || draft.items.length === 0));
if (showSpinner) {
return (
<div style={{ textAlign: "center", padding: 50 }}>
<Spin size="large" />
</div>
);
}
if (draftQuery.isError || !draft) {
return <Alert type="error" message="Ошибка загрузки черновика" />;
if (!draftId) {
return null;
}
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>
</div>
{/* Правая часть хедера: Кнопка чека, Кнопка перетаскивания и Кнопка удаления */}
<div style={{ display: "flex", gap: 8 }}>
{/* Кнопка просмотра чека (только если есть URL) */}
{draft.photo_url && (
<Button
icon={<FileImageOutlined />}
onClick={() => setPreviewVisible(true)}
size="small"
>
Чек
</Button>
)}
{/* Кнопка переключения режима перетаскивания */}
<Button
type={isReordering ? "primary" : "default"}
icon={<SwapOutlined rotate={90} />}
onClick={() => setIsReordering(!isReordering)}
size="small"
>
{isReordering ? "Ок" : ""}
</Button>
<Button
danger={isCanceled}
type={isCanceled ? "primary" : "default"}
icon={isCanceled ? <DeleteOutlined /> : <RestOutlined />}
onClick={handleDelete}
loading={deleteDraftMutation.isPending}
size="small"
>
{isCanceled ? "Удалить" : "Отмена"}
</Button>
</div>
</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="incoming_document_number"
style={{ marginBottom: 8 }}
>
<Input placeholder="№ Документа" size="middle" />
</Form.Item>
</Col>
</Row>
<Row gutter={10}>
<Col span={24}>
<Form.Item
label="Склад"
name="store_id"
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>
{/* Items List */}
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<Droppable droppableId="draft-items">
{(provided, snapshot) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
style={{
display: "flex",
flexDirection: "column",
gap: 8,
backgroundColor: snapshot.isDraggingOver
? "#f0f0f0"
: "transparent",
borderRadius: "4px",
padding: snapshot.isDraggingOver ? "8px" : "0",
transition: "background-color 0.2s ease",
}}
>
{draft.items.map((item, index) => (
<DraftItemRow
key={item.id}
item={item}
index={index}
onUpdate={handleItemUpdate}
onDelete={(itemId) => deleteItemMutation.mutate(itemId)}
isUpdating={updatingItems.has(item.id)}
recommendations={recommendationsQuery.data || []}
isReordering={isReordering}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{/* Кнопка добавления позиции */}
<Button
type="dashed"
block
icon={<PlusOutlined />}
style={{ marginTop: 12, marginBottom: 80, height: 48 }}
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",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</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>
{/* Скрытый компонент для просмотра изображения */}
{draft.photo_url && (
<div style={{ display: "none" }}>
<Image.PreviewGroup
preview={{
visible: previewVisible,
onVisibleChange: (vis) => setPreviewVisible(vis),
movable: true,
scaleStep: 0.5,
}}
>
<Image src={getStaticUrl(draft.photo_url)} />
</Image.PreviewGroup>
</div>
)}
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
<DraftEditor draftId={draftId} onBack={() => navigate("/invoices")} />
</div>
);
};

View File

@@ -1,211 +1,19 @@
// src/pages/InvoiceViewPage.tsx
import React, { useState } from "react";
import React from "react";
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Spin, Alert, Button, Table, Typography, Tag, Image } from "antd";
import {
ArrowLeftOutlined,
FileImageOutlined,
HistoryOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { api, getStaticUrl } from "../services/api";
import type { DraftStatus } from "../services/types";
const { Title, Text } = Typography;
import { InvoiceViewer } from "../components/invoices/InvoiceViewer";
export const InvoiceViewPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { id: invoiceId } = useParams<{ id: string }>();
const navigate = useNavigate();
// Состояние для просмотра фото чека
const [previewVisible, setPreviewVisible] = useState(false);
// Запрос данных накладной
const {
data: invoice,
isLoading,
isError,
} = useQuery({
queryKey: ["invoice", id],
queryFn: () => api.getInvoice(id!),
enabled: !!id,
});
const getStatusTag = (status: DraftStatus) => {
switch (status) {
case "PROCESSING":
return <Tag color="blue">Обработка</Tag>;
case "READY_TO_VERIFY":
return <Tag color="orange">Проверка</Tag>;
case "COMPLETED":
return (
<Tag icon={<HistoryOutlined />} color="success">
Синхронизировано
</Tag>
);
case "ERROR":
return <Tag color="red">Ошибка</Tag>;
case "CANCELED":
return <Tag color="default">Отменен</Tag>;
default:
return <Tag>{status}</Tag>;
}
};
if (isLoading) {
return (
<div style={{ textAlign: "center", padding: 50 }}>
<Spin size="large" />
</div>
);
}
if (isError || !invoice) {
return <Alert type="error" message="Ошибка загрузки накладной" />;
}
const columns = [
{
title: "Товар",
dataIndex: "name",
key: "name",
},
{
title: "Кол-во",
dataIndex: "quantity",
key: "quantity",
align: "right" as const,
},
{
title: "Сумма",
dataIndex: "total",
key: "total",
align: "right" as const,
render: (total: number) =>
total.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
}),
},
];
const totalSum = (invoice.items || []).reduce(
(acc, item) => acc + item.total,
0
);
return (
<div style={{ paddingBottom: 20 }}>
{/* Header */}
<div
style={{
marginBottom: 16,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flex: 1,
minWidth: 0,
}}
>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate("/invoices")}
size="small"
/>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<Title level={4} style={{ margin: 0 }}>
{invoice.number}
</Title>
<Text type="secondary" style={{ fontSize: 12 }}>
{invoice.date} {invoice.supplier.name}
</Text>
</div>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{getStatusTag(invoice.status)}
{/* Кнопка просмотра чека (только если есть URL) */}
{invoice.photo_url && (
<Button
icon={<FileImageOutlined />}
onClick={() => setPreviewVisible(true)}
size="small"
>
Чек
</Button>
)}
</div>
</div>
{/* Таблица товаров */}
<div
style={{
background: "#fff",
padding: 16,
borderRadius: 8,
marginBottom: 16,
}}
>
<Title level={5} style={{ marginBottom: 16 }}>
Товары ({(invoice.items || []).length} поз.)
</Title>
<Table
columns={columns}
dataSource={invoice.items || []}
pagination={false}
rowKey="name"
size="small"
summary={() => (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={2}>
<Text strong>Итого:</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} align="right">
<Text strong>
{totalSum.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
})}
</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
)}
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
{invoiceId && (
<InvoiceViewer
invoiceId={invoiceId}
onBack={() => navigate("/invoices")}
/>
</div>
{/* Скрытый компонент для просмотра изображения */}
{invoice.photo_url && (
<div style={{ display: "none" }}>
<Image.PreviewGroup
preview={{
visible: previewVisible,
onVisibleChange: (vis) => setPreviewVisible(vis),
movable: true,
scaleStep: 0.5,
}}
>
<Image src={getStaticUrl(invoice.photo_url)} />
</Image.PreviewGroup>
</div>
)}
</div>
);

View File

@@ -1,6 +1,11 @@
import React from "react";
import { Typography, Card, List, Empty } from "antd";
import { DragDropZone } from "../../../components/DragDropZone";
import React, { useState } from "react";
import { Typography, Card, List, Empty, Tag, Spin } from "antd";
import { UploadOutlined } from "@ant-design/icons";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { DraftVerificationModal } from "../../../components/invoices/DraftVerificationModal";
import { api } from "../../../services/api";
import { useServerStore } from "../../../stores/serverStore";
import type { UnifiedInvoice } from "../../../services/types";
const { Title } = Typography;
@@ -9,45 +14,129 @@ const { Title } = Typography;
* Содержит зону для загрузки файлов и список черновиков
*/
export const InvoicesDashboard: React.FC = () => {
const handleDrop = (files: File[]) => {
console.log("Файлы загружены:", files);
// TODO: Добавить логику обработки файлов
const { activeServer } = useServerStore();
const queryClient = useQueryClient();
// Состояние для Drag-n-Drop
const [isDragOver, setIsDragOver] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [verificationDraftId, setVerificationDraftId] = useState<string | null>(
null
);
// Загружаем список черновиков через useQuery
const { data: drafts, isLoading } = useQuery({
queryKey: ["drafts", activeServer?.id],
queryFn: () => api.getDrafts(),
enabled: !!activeServer, // Запрос выполняется только если выбран сервер
});
// Обработчики Drag-n-Drop
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
// Проверяем, что перетаскивается файл
if (e.dataTransfer.types.includes("Files")) {
setIsDragOver(true);
}
};
// Заглушка списка черновиков
const mockDrafts = [
{
id: "1",
title: "Черновик #1",
date: "2024-01-15",
status: "В работе",
},
{
id: "2",
title: "Черновик #2",
date: "2024-01-14",
status: "Черновик",
},
];
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
// Проверяем, что мы действительно покидаем элемент, а не просто переходим к дочернему
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
setIsDragOver(false);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
// Разрешаем drop
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
// Берем только первый файл
const file = files[0];
try {
setIsUploading(true);
const draft = await api.uploadFile(file);
// Обновляем список черновиков
queryClient.invalidateQueries({ queryKey: ["drafts", activeServer?.id] });
// Открываем модальное окно проверки
setVerificationDraftId(draft.id);
} catch (error) {
console.error("Ошибка загрузки файла:", error);
} finally {
setIsUploading(false);
}
};
const handleCloseVerification = () => {
setVerificationDraftId(null);
};
// Если сервер не выбран, показываем сообщение
if (!activeServer) {
return (
<div>
<Title level={2}>Черновики</Title>
<Card>
<Empty
description="Выберите сервер сверху"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</Card>
</div>
);
}
return (
<div>
<div
style={{ position: "relative" }}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<Title level={2}>Черновики</Title>
{/* Зона для загрузки файлов */}
<Card style={{ marginBottom: "24px" }}>
<DragDropZone onDrop={handleDrop} />
</Card>
{/* Список черновиков (заглушка) */}
<Card title="Последние черновики">
{/* Список черновиков */}
<Card title="Последние черновики" loading={isLoading}>
<List
dataSource={mockDrafts}
renderItem={(draft) => (
dataSource={drafts || []}
renderItem={(draft: UnifiedInvoice) => (
<List.Item>
<List.Item.Meta
title={draft.title}
description={`${draft.date}${draft.status}`}
title={
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span>{draft.document_number}</span>
{draft.type === "DRAFT" && <Tag color="blue">Черновик</Tag>}
{draft.type === "SYNCED" && (
<Tag color="green">Синхронизировано</Tag>
)}
</div>
}
description={`${draft.date_incoming}${
draft.store_name || "Склад не указан"
}${draft.items_count} позиций`}
/>
</List.Item>
)}
@@ -61,6 +150,65 @@ export const InvoicesDashboard: React.FC = () => {
}}
/>
</Card>
{/* Overlay для Drag-n-Drop */}
{isDragOver && (
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(255, 255, 255, 0.9)",
zIndex: 100,
border: "3px dashed #1890ff",
borderRadius: "8px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
}}
>
<UploadOutlined
style={{ fontSize: "64px", color: "#1890ff", marginBottom: "16px" }}
/>
<Typography.Text style={{ fontSize: "18px", color: "#1890ff" }}>
Отпустите файл для загрузки
</Typography.Text>
</div>
)}
{/* Лоадер загрузки файла */}
{isUploading && (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.5)",
zIndex: 1000,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<Spin size="large" />
<Typography.Text style={{ color: "#fff", marginTop: "16px" }}>
Загрузка файла...
</Typography.Text>
</div>
)}
{/* Модальное окно проверки черновика */}
{verificationDraftId && (
<DraftVerificationModal
draftId={verificationDraftId}
visible={!!verificationDraftId}
onClose={handleCloseVerification}
/>
)}
</div>
);
};

View File

@@ -28,7 +28,8 @@ import type {
ServerUser,
UserRole,
InvoiceDetails,
GetPhotosResponse
GetPhotosResponse,
ServerShort
} from './types';
// Интерфейс для ответа метода инициализации десктопной авторизации
@@ -212,6 +213,11 @@ export const api = {
return data;
},
updateDraft: async (id: string, payload: Partial<CommitDraftRequest>): Promise<DraftInvoice> => {
const { data } = await apiClient.patch<DraftInvoice>(`/drafts/${id}`, payload);
return data;
},
updateDraftItem: async (draftId: string, itemId: string, payload: UpdateDraftItemRequest): Promise<DraftInvoice> => {
const { data } = await apiClient.patch<DraftInvoice>(`/drafts/${draftId}/items/${itemId}`, payload);
return data;
@@ -314,5 +320,31 @@ export const api = {
const { data } = await apiClient.post<InitDesktopAuthResponse>('/auth/init-desktop');
return data;
},
// --- Управление серверами ---
getUserServers: async (): Promise<ServerShort[]> => {
const { data } = await apiClient.get<ServerShort[]>('/user/servers');
return data;
},
switchServer: async (serverId: string): Promise<{ status: string }> => {
const { data } = await apiClient.post<{ status: string }>(`/user/servers/active`, { server_id: serverId });
return data;
},
// --- Загрузка файлов для создания черновика ---
uploadFile: async (file: File): Promise<DraftInvoice> => {
const formData = new FormData();
formData.append('file', file);
const { data } = await apiClient.post<DraftInvoice>('/drafts/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return data;
},
};

View File

@@ -5,6 +5,14 @@ export type UUID = string;
// Добавляем типы ролей
export type UserRole = 'OWNER' | 'ADMIN' | 'OPERATOR';
// Краткая информация о сервере
export interface ServerShort {
id: string;
name: string;
role: UserRole;
is_active: boolean;
}
// Интерфейс пользователя сервера
export interface ServerUser {
user_id: string;
@@ -185,8 +193,8 @@ export interface DraftItem {
// Мета-данные
is_matched: boolean;
product?: CatalogItem;
container?: ProductContainer;
product?: CatalogItem | null;
container?: ProductContainer;
// Поля для синхронизации состояния (опционально, если бэкенд их отдает)
last_edited_field_1?: string;
@@ -221,7 +229,7 @@ export interface DraftInvoice {
// DTO для обновления строки
export interface UpdateDraftItemRequest {
product_id?: UUID;
product_id?: UUID | null;
container_id?: UUID | null;
quantity?: number;
price?: number;

View File

@@ -0,0 +1,185 @@
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import type { DraftItem } from '../services/types';
import { recalculateItem } from '../utils/calculations';
/**
* Интерфейс хранилища активного черновика
*
* Управляет локальным состоянием элементов черновика накладной.
* Отслеживает изменения через флаг isDirty.
*/
interface ActiveDraftStore {
/** Массив элементов черновика */
items: DraftItem[];
/** Флаг, указывающий на наличие несохраненных изменений */
isDirty: boolean;
/**
* Заменяет весь массив элементов черновика
*
* @param items - Новый массив элементов
*/
setItems: (items: DraftItem[]) => void;
/**
* Обновляет элемент черновика по идентификатору
*
* При изменении числовых полей (quantity, price, sum) автоматически
* пересчитывает зависимые значения с помощью recalculateItem.
*
* @param id - Идентификатор элемента для обновления
* @param changes - Частичные изменения элемента
*
* @remarks
* Если меняется product_id, quantity и price НЕ сбрасываются в 0.
* При изменении любого поля устанавливает isDirty = true.
*/
updateItem: (id: string, changes: Partial<DraftItem>) => void;
/**
* Удаляет элемент черновика по идентификатору
*
* @param id - Идентификатор элемента для удаления
*/
deleteItem: (id: string) => void;
/**
* Добавляет новый элемент в черновик
*
* @param item - Новый элемент для добавления
*/
addItem: (item: DraftItem) => void;
/**
* Перемещает элемент с одной позиции на другую
*
* @param startIndex - Исходная позиция элемента
* @param endIndex - Новая позиция элемента
*/
reorderItems: (startIndex: number, endIndex: number) => void;
/**
* Сбрасывает флаг isDirty в false
* Используется после успешного сохранения изменений на сервер
*/
resetDirty: () => void;
}
/**
* Zustand стор для управления состоянием активного черновика
*
* Использует immer middleware для иммутабельных обновлений состояния.
* Все изменения элементов автоматически устанавливают флаг isDirty = true,
* кроме метода setItems, который сбрасывает флаг в false.
*/
export const useActiveDraftStore = create<ActiveDraftStore>()(
immer((set) => ({
// Начальное состояние
items: [],
isDirty: false,
/**
* Заменяет весь массив элементов черновика
* Сбрасывает флаг isDirty в false
*/
setItems: (items: DraftItem[]) =>
set((state) => {
state.items = items;
state.isDirty = false;
}),
/**
* Обновляет элемент черновика по идентификатору
*
* Логика:
* 1. Находит элемент по id
* 2. Если меняются числовые поля (quantity, price, sum), использует recalculateItem
* 3. Устанавливает isDirty = true
* 4. При изменении product_id НЕ сбрасывает quantity и price
*/
updateItem: (id: string, changes: Partial<DraftItem>) =>
set((state) => {
const itemIndex = state.items.findIndex((item: DraftItem) => item.id === id);
if (itemIndex === -1) {
return;
}
const item = state.items[itemIndex];
// Проверяем, изменились ли числовые поля
const numericFields: Array<keyof DraftItem> = ['quantity', 'price', 'sum'];
const changedNumericField = numericFields.find(
(field) =>
changes[field] !== undefined &&
changes[field] !== item[field]
);
if (changedNumericField) {
// Используем recalculateItem для пересчета зависимых полей
const newValue = changes[changedNumericField] as number;
const recalculated = recalculateItem(
item,
changedNumericField as 'quantity' | 'price' | 'sum',
newValue
);
// Применяем пересчитанные значения и остальные изменения
state.items[itemIndex] = {
...recalculated,
...changes,
};
} else {
// Просто применяем изменения без пересчета
state.items[itemIndex] = {
...item,
...changes,
};
}
state.isDirty = true;
}),
/**
* Удаляет элемент черновика по идентификатору
* Устанавливает isDirty = true
*/
deleteItem: (id: string) =>
set((state) => {
state.items = state.items.filter((item: DraftItem) => item.id !== id);
state.isDirty = true;
}),
/**
* Добавляет новый элемент в черновик
* Устанавливает isDirty = true
*/
addItem: (item: DraftItem) =>
set((state) => {
state.items.push(item);
state.isDirty = true;
}),
/**
* Перемещает элемент с позиции startIndex на endIndex
* Устанавливает isDirty = true
*/
reorderItems: (startIndex: number, endIndex: number) =>
set((state) => {
const [removed] = state.items.splice(startIndex, 1);
state.items.splice(endIndex, 0, removed);
state.isDirty = true;
}),
/**
* Сбрасывает флаг isDirty в false
* Используется после успешного сохранения изменений на сервер
*/
resetDirty: () =>
set((state) => {
state.isDirty = false;
}),
}))
);

View File

@@ -0,0 +1,54 @@
import { create } from 'zustand';
import type { ServerShort } from '../services/types';
import { api } from '../services/api';
interface ServerState {
servers: ServerShort[];
activeServer: ServerShort | null;
isLoading: boolean;
error: string | null;
fetchServers: () => Promise<void>;
setActiveServer: (id: string) => Promise<void>;
}
/**
* Хранилище состояния серверов
* Управляет списком доступных серверов и текущим активным сервером
*/
export const useServerStore = create<ServerState>((set, get) => ({
servers: [],
activeServer: null,
isLoading: false,
error: null,
fetchServers: async () => {
set({ isLoading: true, error: null });
try {
const servers = await api.getUserServers();
const activeServer = servers.find(s => s.is_active) || null;
set({ servers, activeServer, isLoading: false });
} catch (error) {
console.error('Ошибка при загрузке списка серверов:', error);
set({
error: error instanceof Error ? error.message : 'Не удалось загрузить список серверов',
isLoading: false
});
}
},
setActiveServer: async (id: string) => {
set({ isLoading: true, error: null });
try {
await api.switchServer(id);
// После успешного переключения перезагружаем список серверов
// чтобы обновить флаг is_active
await get().fetchServers();
} catch (error) {
console.error('Ошибка при переключении сервера:', error);
set({
error: error instanceof Error ? error.message : 'Не удалось переключить сервер',
isLoading: false
});
}
},
}));

View File

@@ -0,0 +1,66 @@
import type { DraftItem } from '../services/types';
/**
* Пересчитывает значения полей элемента черновика на основе измененного поля.
*
* @param item - Исходный элемент черновика
* @param changedField - Измененное поле ('quantity' | 'price' | 'sum')
* @param newValue - Новое значение измененного поля
* @returns Новый объект DraftItem с пересчитанными значениями
*
* @example
* // При изменении количества
* const updated = recalculateItem(item, 'quantity', 5);
*
* @example
* // При изменении суммы
* const updated = recalculateItem(item, 'sum', 100);
*/
export function recalculateItem(
item: DraftItem,
changedField: 'quantity' | 'price' | 'sum',
newValue: number
): DraftItem {
switch (changedField) {
case 'quantity': {
// При изменении количества пересчитываем сумму: sum = qty * price
return {
...item,
quantity: newValue,
sum: newValue * item.price,
};
}
case 'price': {
// При изменении цены пересчитываем сумму: sum = qty * price
return {
...item,
price: newValue,
sum: item.quantity * newValue,
};
}
case 'sum': {
// При изменении суммы пересчитываем цену: price = sum / qty
// Обрабатываем случай деления на ноль
if (item.quantity === 0) {
return {
...item,
sum: newValue,
price: 0,
};
}
return {
...item,
sum: newValue,
price: newValue / item.quantity,
};
}
default: {
// Для неизвестных полей возвращаем исходный объект
return { ...item };
}
}
}