mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
2901-zustend для стора. сохранение черновиков построчно
редактор xml пока не работает, но есть ui переработал
This commit is contained in:
762
rmser-view/src/components/invoices/DraftEditor.tsx
Normal file
762
rmser-view/src/components/invoices/DraftEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user