mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
2701-как будто ок днд работает
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo, useState, useEffect, useRef } from "react";
|
||||
import { Draggable } from "@hello-pangea/dnd";
|
||||
import {
|
||||
Card,
|
||||
Flex,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
WarningFilled,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import { CatalogSelect } from "../ocr/CatalogSelect";
|
||||
import { CreateContainerModal } from "./CreateContainerModal";
|
||||
import type {
|
||||
@@ -31,6 +33,7 @@ const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
item: DraftItem;
|
||||
index: number;
|
||||
onUpdate: (itemId: string, changes: UpdateDraftItemRequest) => void;
|
||||
onDelete: (itemId: string) => void;
|
||||
isUpdating: boolean;
|
||||
@@ -41,6 +44,7 @@ type FieldType = "quantity" | "price" | "sum";
|
||||
|
||||
export const DraftItemRow: React.FC<Props> = ({
|
||||
item,
|
||||
index,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
isUpdating,
|
||||
@@ -287,161 +291,227 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
borderLeft: `4px solid ${cardBorderColor}`,
|
||||
background: item.product_id ? "#fff" : "#fff1f0",
|
||||
}}
|
||||
bodyStyle={{ padding: 12 }}
|
||||
>
|
||||
<Flex vertical gap={10}>
|
||||
<Flex justify="space-between" align="start">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 12, lineHeight: 1.2, display: "block" }}
|
||||
<Draggable draggableId={item.id} index={index}>
|
||||
{(provided, snapshot) => {
|
||||
const style = {
|
||||
marginBottom: "8px",
|
||||
backgroundColor: snapshot.isDragging ? "#e6f7ff" : "transparent",
|
||||
boxShadow: snapshot.isDragging
|
||||
? "0 4px 12px rgba(0, 0, 0, 0.15)"
|
||||
: "none",
|
||||
borderRadius: "4px",
|
||||
transition: "background-color 0.2s ease, box-shadow 0.2s ease",
|
||||
...provided.draggableProps.style,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={style}
|
||||
>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "12px 16px",
|
||||
borderLeft: `4px solid ${cardBorderColor}`,
|
||||
border: snapshot.isDragging
|
||||
? "2px solid #1890ff"
|
||||
: "1px solid #d9d9d9",
|
||||
background: item.product_id ? "#fff" : "#fff1f0",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
{item.raw_name || "Новая позиция"}
|
||||
</Text>
|
||||
{item.raw_amount > 0 && (
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 10, display: "block" }}
|
||||
{/* Drag handle - иконка для перетаскивания */}
|
||||
<div
|
||||
{...provided.dragHandleProps}
|
||||
style={{
|
||||
cursor: "grab",
|
||||
padding: "4px 8px 4px 0",
|
||||
color: "#8c8c8c",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
transition: "color 0.2s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = "#1890ff";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = "#8c8c8c";
|
||||
}}
|
||||
>
|
||||
(чек: {item.raw_amount} x {item.raw_price})
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{isUpdating && <SyncOutlined spin style={{ color: "#1890ff" }} />}
|
||||
<GripVertical size={20} />
|
||||
</div>
|
||||
|
||||
{activeWarning && (
|
||||
<WarningFilled
|
||||
style={{ color: "#faad14", fontSize: 16, cursor: "pointer" }}
|
||||
onClick={showWarningModal}
|
||||
/>
|
||||
)}
|
||||
<Flex vertical gap={10} style={{ flex: 1 }}>
|
||||
<Flex justify="space-between" align="start">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
lineHeight: 1.2,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{item.raw_name || "Новая позиция"}
|
||||
</Text>
|
||||
{item.raw_amount > 0 && (
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 10, display: "block" }}
|
||||
>
|
||||
(чек: {item.raw_amount} x {item.raw_price})
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{isUpdating && (
|
||||
<SyncOutlined spin style={{ color: "#1890ff" }} />
|
||||
)}
|
||||
|
||||
{!item.product_id && (
|
||||
<Tag color="error" style={{ margin: 0 }}>
|
||||
?
|
||||
</Tag>
|
||||
)}
|
||||
{activeWarning && (
|
||||
<WarningFilled
|
||||
style={{
|
||||
color: "#faad14",
|
||||
fontSize: 16,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={showWarningModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Popconfirm
|
||||
title="Удалить строку?"
|
||||
onConfirm={() => onDelete(item.id)}
|
||||
okText="Да"
|
||||
cancelText="Нет"
|
||||
placement="left"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
danger
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</Flex>
|
||||
{!item.product_id && (
|
||||
<Tag color="error" style={{ margin: 0 }}>
|
||||
?
|
||||
</Tag>
|
||||
)}
|
||||
|
||||
<CatalogSelect
|
||||
value={item.product_id || undefined}
|
||||
onChange={handleProductChange}
|
||||
initialProduct={activeProduct}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="Удалить строку?"
|
||||
onConfirm={() => onDelete(item.id)}
|
||||
okText="Да"
|
||||
cancelText="Нет"
|
||||
placement="left"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
danger
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
{activeProduct && (
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Выберите единицу измерения"
|
||||
options={containerOptions}
|
||||
value={item.container_id || "BASE_UNIT"}
|
||||
onChange={handleContainerChange}
|
||||
dropdownRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
<Divider style={{ margin: "4px 0" }} />
|
||||
<Button
|
||||
type="text"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
style={{ textAlign: "left" }}
|
||||
<CatalogSelect
|
||||
value={item.product_id || undefined}
|
||||
onChange={handleProductChange}
|
||||
initialProduct={activeProduct}
|
||||
/>
|
||||
|
||||
{activeProduct && (
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Выберите единицу измерения"
|
||||
options={containerOptions}
|
||||
value={item.container_id || "BASE_UNIT"}
|
||||
onChange={handleContainerChange}
|
||||
dropdownRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
<Divider style={{ margin: "4px 0" }} />
|
||||
<Button
|
||||
type="text"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
style={{ textAlign: "left" }}
|
||||
>
|
||||
Добавить фасовку...
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
background: "#fafafa",
|
||||
margin: "0 -12px -12px -12px",
|
||||
padding: "8px 12px",
|
||||
borderTop: "1px solid #f0f0f0",
|
||||
borderBottomLeftRadius: 8,
|
||||
borderBottomRightRadius: 8,
|
||||
}}
|
||||
>
|
||||
Добавить фасовку...
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: 70 }}
|
||||
controls={false}
|
||||
placeholder="Кол"
|
||||
min={0}
|
||||
value={localQty}
|
||||
onChange={(val) => handleValueChange("quantity", val)}
|
||||
onBlur={() => handleBlur("quantity")}
|
||||
precision={3}
|
||||
/>
|
||||
<Text type="secondary">x</Text>
|
||||
<InputNumber
|
||||
style={{ width: 80 }}
|
||||
controls={false}
|
||||
placeholder="Цена"
|
||||
min={0}
|
||||
value={localPrice}
|
||||
onChange={(val) => handleValueChange("price", val)}
|
||||
onBlur={() => handleBlur("price")}
|
||||
precision={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
background: "#fafafa",
|
||||
margin: "0 -12px -12px -12px",
|
||||
padding: "8px 12px",
|
||||
borderTop: "1px solid #f0f0f0",
|
||||
borderBottomLeftRadius: 8,
|
||||
borderBottomRightRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", gap: 8, alignItems: "center", flex: 1 }}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: 70 }}
|
||||
controls={false}
|
||||
placeholder="Кол"
|
||||
min={0}
|
||||
value={localQty}
|
||||
onChange={(val) => handleValueChange("quantity", val)}
|
||||
onBlur={() => handleBlur("quantity")}
|
||||
precision={3}
|
||||
/>
|
||||
<Text type="secondary">x</Text>
|
||||
<InputNumber
|
||||
style={{ width: 80 }}
|
||||
controls={false}
|
||||
placeholder="Цена"
|
||||
min={0}
|
||||
value={localPrice}
|
||||
onChange={(val) => handleValueChange("price", val)}
|
||||
onBlur={() => handleBlur("price")}
|
||||
precision={2}
|
||||
/>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<Text type="secondary">=</Text>
|
||||
<InputNumber
|
||||
style={{ width: 90, fontWeight: "bold" }}
|
||||
controls={false}
|
||||
placeholder="Сумма"
|
||||
min={0}
|
||||
value={localSum}
|
||||
onChange={(val) => handleValueChange("sum", val)}
|
||||
onBlur={() => handleBlur("sum")}
|
||||
precision={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Flex>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<Text type="secondary">=</Text>
|
||||
<InputNumber
|
||||
style={{ width: 90, fontWeight: "bold" }}
|
||||
controls={false}
|
||||
placeholder="Сумма"
|
||||
min={0}
|
||||
value={localSum}
|
||||
onChange={(val) => handleValueChange("sum", val)}
|
||||
onBlur={() => handleBlur("sum")}
|
||||
precision={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
);
|
||||
}}
|
||||
</Draggable>
|
||||
{activeProduct && (
|
||||
<CreateContainerModal
|
||||
visible={isModalOpen}
|
||||
|
||||
@@ -33,7 +33,9 @@ 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;
|
||||
@@ -46,6 +48,9 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
|
||||
const [itemsOrder, setItemsOrder] = useState<Record<string, number>>({});
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Состояние для просмотра фото чека
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
@@ -68,6 +73,7 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
queryFn: () => api.getDraft(id!),
|
||||
enabled: !!id,
|
||||
refetchInterval: (query) => {
|
||||
if (isDragging) return false;
|
||||
const status = query.state.data?.status;
|
||||
return status === "PROCESSING" ? 3000 : false;
|
||||
},
|
||||
@@ -150,7 +156,25 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const reorderItemsMutation = useMutation({
|
||||
mutationFn: ({
|
||||
draftId,
|
||||
payload,
|
||||
}: {
|
||||
draftId: string;
|
||||
payload: ReorderDraftItemsRequest;
|
||||
}) => api.reorderDraftItems(draftId, payload),
|
||||
onError: (error) => {
|
||||
message.error("Не удалось изменить порядок элементов");
|
||||
console.error("Reorder error:", error);
|
||||
},
|
||||
});
|
||||
|
||||
// --- ЭФФЕКТЫ ---
|
||||
useEffect(() => {
|
||||
setEnabled(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (draft) {
|
||||
const currentValues = form.getFieldsValue();
|
||||
@@ -241,6 +265,64 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
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 ||
|
||||
@@ -444,19 +526,65 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Items List */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{draft.items.map((item) => (
|
||||
<DraftItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onUpdate={handleItemUpdate}
|
||||
// Передаем обработчик удаления
|
||||
onDelete={(itemId) => deleteItemMutation.mutate(itemId)}
|
||||
isUpdating={updatingItems.has(item.id)}
|
||||
recommendations={recommendationsQuery.data || []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{enabled ? (
|
||||
<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 || []}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{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 || []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопка добавления позиции */}
|
||||
<Button
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
DraftItem,
|
||||
UpdateDraftItemRequest,
|
||||
CommitDraftRequest,
|
||||
ReorderDraftItemsRequest,
|
||||
ProductSearchResult,
|
||||
AddContainerRequest,
|
||||
AddContainerResponse,
|
||||
@@ -208,6 +209,10 @@ export const api = {
|
||||
await apiClient.delete(`/drafts/${draftId}/items/${itemId}`);
|
||||
},
|
||||
|
||||
reorderDraftItems: async (draftId: string, payload: ReorderDraftItemsRequest): Promise<void> => {
|
||||
await apiClient.post(`/drafts/${draftId}/reorder`, payload);
|
||||
},
|
||||
|
||||
commitDraft: async (draftId: string, payload: CommitDraftRequest): Promise<{ document_number: string }> => {
|
||||
const { data } = await apiClient.post<{ document_number: string }>(`/drafts/${draftId}/commit`, payload);
|
||||
return data;
|
||||
|
||||
@@ -237,6 +237,14 @@ export interface CommitDraftRequest {
|
||||
comment: string;
|
||||
incoming_document_number?: string;
|
||||
}
|
||||
|
||||
export interface ReorderDraftItemsRequest {
|
||||
items: Array<{
|
||||
id: UUID;
|
||||
order: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MainUnit {
|
||||
id: UUID;
|
||||
name: string; // "кг"
|
||||
|
||||
Reference in New Issue
Block a user