Files
rmser/rmser-view/src/components/invoices/DraftEditor.tsx
SERTY 88620f3fb6 0202-финиш перед десктопом
пересчет поправил
редактирование с перепроведением
галка автопроведения работает
рекомендации починил
2026-02-02 13:53:38 +03:00

777 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
Checkbox,
} 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,
UpdateDraftRequest,
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,
markAsDirty,
} = 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 [isProcessed, setIsProcessed] = useState(true); // По умолчанию true для MVP
// --- ЗАПРОСЫ ---
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) {
// Инициализируем только если изменился draftId или стор пуст
if (currentDraftIdRef.current !== draft.id || items.length === 0) {
// 1. Инициализация строк (Store)
setItems(draft.items || []);
// 2. Инициализация шапки (Form)
form.setFieldsValue({
store_id: draft.store_id,
supplier_id: draft.supplier_id,
comment: draft.comment,
incoming_document_number: draft.incoming_document_number,
date_incoming: draft.date_incoming
? dayjs(draft.date_incoming)
: dayjs(),
});
currentDraftIdRef.current = draft.id;
}
}
}, [draft, items.length, setItems, 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 payload: UpdateDraftRequest = {
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,
items: items.map((item) => ({
id: item.id,
product_id: item.product_id ?? "",
container_id: item.container_id ?? "",
quantity: Number(item.quantity),
price: Number(item.price),
sum: Number(item.sum),
})),
};
// Отправляем единый запрос на сервер
await api.updateDraft(draftId, payload);
// После успешного сохранения обновляем данные с сервера
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 || "",
is_processed: isProcessed,
});
} catch {
message.error("Заполните обязательные поля (Склад, Поставщик)");
}
};
const isCanceled = draft?.status === "CANCELED";
const isCompleted = draft?.status === "COMPLETED";
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: 6,
borderRadius: 8,
marginBottom: 12,
opacity: isCanceled ? 0.6 : 1,
}}
>
<Form
form={form}
layout="vertical"
onValuesChange={() => markAsDirty()}
>
<Row gutter={[4, 4]}>
<Col span={12}>
<Form.Item
name="date_incoming"
label="Дата"
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"
label="№ входящего"
style={{ marginBottom: 0 }}
>
<Input placeholder="" size="small" />
</Form.Item>
</Col>
</Row>
<Row gutter={[4, 4]}>
<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={{
position: "relative",
paddingLeft: 40,
height: 36,
}}
size="small"
>
<Checkbox
checked={isProcessed}
onChange={(e) => setIsProcessed(e.target.checked)}
onClick={(e) => e.stopPropagation()}
disabled={invalidItemsCount > 0 || isCanceled}
style={{
position: "absolute",
left: 10,
top: "50%",
transform: "translateY(-50%)",
pointerEvents: "auto",
}}
/>
<span style={{ marginLeft: 8 }}>
{isCanceled
? "Восстановить"
: isCompleted
? "Обновить в iiko"
: isProcessed
? "Провести и отправить"
: "Сохранить (без проведения)"}
</span>
</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 || ""}
/>
</div>
);
};