mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
652 lines
20 KiB
TypeScript
652 lines
20 KiB
TypeScript
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<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="Ошибка загрузки черновика" />;
|
||
}
|
||
|
||
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>
|
||
);
|
||
};
|