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 = ({ 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(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) => { // Обновляем локально через стор 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: , content: "У вас есть несохраненные изменения. Сохранить их перед выходом?", okText: "Сохранить и выйти", cancelText: "Выйти без сохранения", onOk: async () => { try { await saveChanges(); onBack?.(); } catch { // Ошибка уже обработана в saveChanges } }, onCancel: () => { onBack?.(); }, }); } else { onBack?.(); } }; const handleDelete = () => { confirm({ title: isCanceled ? "Удалить окончательно?" : "Отменить черновик?", icon: , 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 (
); } if (draftQuery.isError || !draft) { return ; } return (
{/* Единый хедер */}
{/* Левая часть: Кнопка назад + Номер + Статус */}
{onBack && (
{/* Правая часть: Кнопки действий */}
{/* Кнопка сохранения (показывается только если есть несохраненные изменения) */} {isDirty && ( )} {/* Кнопка просмотра чека (только если есть URL) */} {draft.photo_url && (
{/* Основная часть с прокруткой */}
{/* Form: Склады и Поставщики */}
markAsDirty()} > ({ label: s.name, value: s.id }))} size="small" showSearch filterOption={(input, option) => (option?.label ?? "") .toLowerCase() .includes(input.toLowerCase()) } />