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

@@ -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>
);
};