import React, { useEffect, useMemo, useState } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Spin, Alert, Button, Form, Select, DatePicker, Input, Typography, message, Row, Col, Affix, Modal, Tag, 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; export const InvoiceDraftPage: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const queryClient = useQueryClient(); const [form] = Form.useForm(); const [updatingItems, setUpdatingItems] = useState>(new Set()); const [itemsOrder, setItemsOrder] = useState>({}); 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: , 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 (
); } if (draftQuery.isError || !draft) { return ; } return (
{/* Header */}
{/* Правая часть хедера: Кнопка чека, Кнопка перетаскивания и Кнопка удаления */}
{/* Кнопка просмотра чека (только если есть URL) */} {draft.photo_url && ( )} {/* Кнопка переключения режима перетаскивания */}
{/* Form: Склады и Поставщики */}
{/* Входящий номер */} ({ label: s.name, value: s.id }))} size="middle" showSearch filterOption={(input, option) => (option?.label ?? "") .toLowerCase() .includes(input.toLowerCase()) } />