2701-как будто ок днд работает

This commit is contained in:
2026-01-27 12:09:54 +03:00
parent de4bd9c8d7
commit 1e2d43be8e
13 changed files with 10257 additions and 175 deletions

View File

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

View File

@@ -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

View File

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

View File

@@ -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; // "кг"