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

@@ -155,7 +155,7 @@ func main() {
api.POST("/drafts/:id/items", draftsHandler.AddDraftItem) api.POST("/drafts/:id/items", draftsHandler.AddDraftItem)
api.DELETE("/drafts/:id/items/:itemId", draftsHandler.DeleteDraftItem) api.DELETE("/drafts/:id/items/:itemId", draftsHandler.DeleteDraftItem)
api.PATCH("/drafts/:id/items/:itemId", draftsHandler.UpdateItem) api.PATCH("/drafts/:id/items/:itemId", draftsHandler.UpdateItem)
api.PUT("/drafts/:id/items/reorder", draftsHandler.ReorderItems) api.POST("/drafts/:id/reorder", draftsHandler.ReorderItems)
api.POST("/drafts/:id/commit", draftsHandler.CommitDraft) api.POST("/drafts/:id/commit", draftsHandler.CommitDraft)
api.POST("/drafts/container", draftsHandler.AddContainer) api.POST("/drafts/container", draftsHandler.AddContainer)

View File

@@ -4,8 +4,8 @@ app:
drop_tables: false drop_tables: false
storage_path: "./uploads" storage_path: "./uploads"
public_url: "https://rmser.serty.top" public_url: "https://rmser.serty.top"
maintenance_mode: true maintenance_mode: false
dev_ids: [665599275,2126923472] # Укажите здесь ваш ID и ID тестировщиков dev_ids: [] # Укажите здесь ваш ID и ID тестировщиков
db: db:
dsn: "host=postgres user=rmser password=mhrcadmin994525 dbname=rmser_db port=5432 sslmode=disable TimeZone=Europe/Moscow" dsn: "host=postgres user=rmser password=mhrcadmin994525 dbname=rmser_db port=5432 sslmode=disable TimeZone=Europe/Moscow"
@@ -15,11 +15,6 @@ redis:
password: "" password: ""
db: 0 db: 0
rms:
base_url: "https://rest-mesto-vstrechi.iiko.it" # Например http://95.12.34.56:8080
login: "MH"
password: "MhLevfqkexit632597" # Пароль в открытом виде (приложение само хеширует)
ocr: ocr:
service_url: "http://ocr-service:5005" service_url: "http://ocr-service:5005"

View File

@@ -91,6 +91,10 @@ type Repository interface {
Update(draft *DraftInvoice) error Update(draft *DraftInvoice) error
UpdateItem(itemID uuid.UUID, updates map[string]interface{}) error UpdateItem(itemID uuid.UUID, updates map[string]interface{}) error
UpdateItemOrder(itemID uuid.UUID, newOrder int) error UpdateItemOrder(itemID uuid.UUID, newOrder int) error
ReorderItems(draftID uuid.UUID, items []struct {
ID uuid.UUID
Order int
}) error
CreateItem(item *DraftInvoiceItem) error CreateItem(item *DraftInvoiceItem) error
DeleteItem(itemID uuid.UUID) error DeleteItem(itemID uuid.UUID) error
Delete(id uuid.UUID) error Delete(id uuid.UUID) error

View File

@@ -1,6 +1,8 @@
package drafts package drafts
import ( import (
"fmt"
"rmser/internal/domain/drafts" "rmser/internal/domain/drafts"
"github.com/google/uuid" "github.com/google/uuid"
@@ -105,6 +107,35 @@ func (r *pgRepository) UpdateItemOrder(itemID uuid.UUID, newOrder int) error {
Update("order", newOrder).Error Update("order", newOrder).Error
} }
// ReorderItems обновляет порядок нескольких элементов в одной транзакции
func (r *pgRepository) ReorderItems(draftID uuid.UUID, items []struct {
ID uuid.UUID
Order int
}) error {
return r.db.Transaction(func(tx *gorm.DB) error {
for _, item := range items {
// Проверяем, что элемент принадлежит указанному черновику
var count int64
if err := tx.Model(&drafts.DraftInvoiceItem{}).
Where("id = ? AND draft_id = ?", item.ID, draftID).
Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fmt.Errorf("элемент с id %s не принадлежит черновику %s", item.ID.String(), draftID.String())
}
// Обновляем только колонку order
if err := tx.Model(&drafts.DraftInvoiceItem{}).
Where("id = ?", item.ID).
Update("order", item.Order).Error; err != nil {
return err
}
}
return nil
})
}
func (r *pgRepository) Delete(id uuid.UUID) error { func (r *pgRepository) Delete(id uuid.UUID) error {
return r.db.Delete(&drafts.DraftInvoice{}, id).Error return r.db.Delete(&drafts.DraftInvoice{}, id).Error
} }

View File

@@ -921,3 +921,32 @@ func (s *Service) ReorderItem(draftID, itemID uuid.UUID, newOrder int) error {
// Обновляем порядок целевого элемента // Обновляем порядок целевого элемента
return s.draftRepo.UpdateItemOrder(itemID, newOrder) return s.draftRepo.UpdateItemOrder(itemID, newOrder)
} }
// ReorderItems обновляет порядок нескольких элементов в черновике
func (s *Service) ReorderItems(draftID uuid.UUID, items []struct {
ID uuid.UUID
Order int
}) error {
// Проверяем, что черновик существует
draft, err := s.draftRepo.GetByID(draftID)
if err != nil {
return fmt.Errorf("черновик не найден: %w", err)
}
// Проверяем, что все элементы принадлежат указанному черновику
for _, item := range items {
found := false
for _, draftItem := range draft.Items {
if draftItem.ID == item.ID {
found = true
break
}
}
if !found {
return fmt.Errorf("элемент с id %s не принадлежит черновику %s", item.ID.String(), draftID.String())
}
}
// Вызываем метод репозитория для обновления порядка
return s.draftRepo.ReorderItems(draftID, items)
}

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"time" "time"
@@ -73,6 +74,14 @@ type UpdateItemDTO struct {
EditedField string `json:"edited_field"` // "quantity", "price", "sum" EditedField string `json:"edited_field"` // "quantity", "price", "sum"
} }
// ReorderItemsRequest DTO для переупорядочивания нескольких элементов
type ReorderItemsRequest struct {
Items []struct {
ID string `json:"id"`
Order int `json:"order"`
} `json:"items"`
}
func (h *DraftsHandler) AddDraftItem(c *gin.Context) { func (h *DraftsHandler) AddDraftItem(c *gin.Context) {
draftID, err := uuid.Parse(c.Param("id")) draftID, err := uuid.Parse(c.Param("id"))
if err != nil { if err != nil {
@@ -303,14 +312,9 @@ func (h *DraftsHandler) DeleteDraft(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": newStatus, "id": id.String()}) c.JSON(http.StatusOK, gin.H{"status": newStatus, "id": id.String()})
} }
// ReorderItems изменяет порядок позиции в черновике // ReorderItems изменяет порядок нескольких позиций в черновике
func (h *DraftsHandler) ReorderItems(c *gin.Context) { func (h *DraftsHandler) ReorderItems(c *gin.Context) {
type ReorderRequest struct { var req ReorderItemsRequest
ItemID uuid.UUID `json:"item_id" binding:"required"`
NewOrder int `json:"new_order" binding:"required"`
}
var req ReorderRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -323,7 +327,27 @@ func (h *DraftsHandler) ReorderItems(c *gin.Context) {
return return
} }
if err := h.service.ReorderItem(draftID, req.ItemID, req.NewOrder); err != nil { // Преобразуем элементы в формат для сервиса
items := make([]struct {
ID uuid.UUID
Order int
}, len(req.Items))
for i, item := range req.Items {
id, err := uuid.Parse(item.ID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid item id: %s", item.ID)})
return
}
items[i] = struct {
ID uuid.UUID
Order int
}{
ID: id,
Order: item.Order,
}
}
if err := h.service.ReorderItems(draftID, items); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }

View File

@@ -8,11 +8,13 @@
"name": "rmser-view", "name": "rmser-view",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1",
"@tanstack/react-query": "^5.90.12", "@tanstack/react-query": "^5.90.12",
"@twa-dev/sdk": "^8.0.2", "@twa-dev/sdk": "^8.0.2",
"antd": "^6.1.0", "antd": "^6.1.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.10.1", "react-router-dom": "^7.10.1",
@@ -1029,6 +1031,23 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@hello-pangea/dnd": {
"version": "18.0.1",
"resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-18.0.1.tgz",
"integrity": "sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.26.7",
"css-box-model": "^1.2.1",
"raf-schd": "^4.0.3",
"react-redux": "^9.2.0",
"redux": "^5.0.1"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -2294,6 +2313,12 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.49.0", "version": "8.49.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz",
@@ -2943,6 +2968,15 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/css-box-model": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
"integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==",
"license": "MIT",
"dependencies": {
"tiny-invariant": "^1.0.6"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -3813,6 +3847,15 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "0.563.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz",
"integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -4055,12 +4098,17 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/raf-schd": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==",
"license": "MIT"
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.1", "version": "19.2.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -4084,6 +4132,29 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.18.0", "version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -4132,6 +4203,13 @@
"react-dom": ">=18" "react-dom": ">=18"
} }
}, },
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -4295,6 +4373,12 @@
"node": ">=12.22" "node": ">=12.22"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -4425,6 +4509,15 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "7.2.7", "version": "7.2.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz",

View File

@@ -10,11 +10,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1",
"@tanstack/react-query": "^5.90.12", "@tanstack/react-query": "^5.90.12",
"@twa-dev/sdk": "^8.0.2", "@twa-dev/sdk": "^8.0.2",
"antd": "^6.1.0", "antd": "^6.1.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.10.1", "react-router-dom": "^7.10.1",

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import React, { useMemo, useState, useEffect, useRef } from "react"; import React, { useMemo, useState, useEffect, useRef } from "react";
import { Draggable } from "@hello-pangea/dnd";
import { import {
Card, Card,
Flex, Flex,
@@ -17,6 +18,7 @@ import {
WarningFilled, WarningFilled,
DeleteOutlined, DeleteOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { GripVertical } from "lucide-react";
import { CatalogSelect } from "../ocr/CatalogSelect"; import { CatalogSelect } from "../ocr/CatalogSelect";
import { CreateContainerModal } from "./CreateContainerModal"; import { CreateContainerModal } from "./CreateContainerModal";
import type { import type {
@@ -31,6 +33,7 @@ const { Text } = Typography;
interface Props { interface Props {
item: DraftItem; item: DraftItem;
index: number;
onUpdate: (itemId: string, changes: UpdateDraftItemRequest) => void; onUpdate: (itemId: string, changes: UpdateDraftItemRequest) => void;
onDelete: (itemId: string) => void; onDelete: (itemId: string) => void;
isUpdating: boolean; isUpdating: boolean;
@@ -41,6 +44,7 @@ type FieldType = "quantity" | "price" | "sum";
export const DraftItemRow: React.FC<Props> = ({ export const DraftItemRow: React.FC<Props> = ({
item, item,
index,
onUpdate, onUpdate,
onDelete, onDelete,
isUpdating, isUpdating,
@@ -287,21 +291,71 @@ export const DraftItemRow: React.FC<Props> = ({
return ( return (
<> <>
<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 <Card
size="small" size="small"
style={{ style={{
marginBottom: 8, display: "flex",
alignItems: "center",
padding: "12px 16px",
borderLeft: `4px solid ${cardBorderColor}`, borderLeft: `4px solid ${cardBorderColor}`,
border: snapshot.isDragging
? "2px solid #1890ff"
: "1px solid #d9d9d9",
background: item.product_id ? "#fff" : "#fff1f0", background: item.product_id ? "#fff" : "#fff1f0",
borderRadius: "4px",
}} }}
bodyStyle={{ padding: 12 }} bodyStyle={{ padding: 0 }}
> >
<Flex vertical gap={10}> {/* 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";
}}
>
<GripVertical size={20} />
</div>
<Flex vertical gap={10} style={{ flex: 1 }}>
<Flex justify="space-between" align="start"> <Flex justify="space-between" align="start">
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Text <Text
type="secondary" type="secondary"
style={{ fontSize: 12, lineHeight: 1.2, display: "block" }} style={{
fontSize: 12,
lineHeight: 1.2,
display: "block",
}}
> >
{item.raw_name || "Новая позиция"} {item.raw_name || "Новая позиция"}
</Text> </Text>
@@ -322,11 +376,17 @@ export const DraftItemRow: React.FC<Props> = ({
gap: 6, gap: 6,
}} }}
> >
{isUpdating && <SyncOutlined spin style={{ color: "#1890ff" }} />} {isUpdating && (
<SyncOutlined spin style={{ color: "#1890ff" }} />
)}
{activeWarning && ( {activeWarning && (
<WarningFilled <WarningFilled
style={{ color: "#faad14", fontSize: 16, cursor: "pointer" }} style={{
color: "#faad14",
fontSize: 16,
cursor: "pointer",
}}
onClick={showWarningModal} onClick={showWarningModal}
/> />
)} )}
@@ -400,7 +460,12 @@ export const DraftItemRow: React.FC<Props> = ({
}} }}
> >
<div <div
style={{ display: "flex", gap: 8, alignItems: "center", flex: 1 }} style={{
display: "flex",
gap: 8,
alignItems: "center",
flex: 1,
}}
> >
<InputNumber <InputNumber
style={{ width: 70 }} style={{ width: 70 }}
@@ -425,7 +490,9 @@ export const DraftItemRow: React.FC<Props> = ({
/> />
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}> <div
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<Text type="secondary">=</Text> <Text type="secondary">=</Text>
<InputNumber <InputNumber
style={{ width: 90, fontWeight: "bold" }} style={{ width: 90, fontWeight: "bold" }}
@@ -441,7 +508,10 @@ export const DraftItemRow: React.FC<Props> = ({
</div> </div>
</Flex> </Flex>
</Card> </Card>
</div>
);
}}
</Draggable>
{activeProduct && ( {activeProduct && (
<CreateContainerModal <CreateContainerModal
visible={isModalOpen} visible={isModalOpen}

View File

@@ -33,7 +33,9 @@ import { DraftItemRow } from "../components/invoices/DraftItemRow";
import type { import type {
UpdateDraftItemRequest, UpdateDraftItemRequest,
CommitDraftRequest, CommitDraftRequest,
ReorderDraftItemsRequest,
} from "../services/types"; } from "../services/types";
import { DragDropContext, Droppable, type DropResult } from "@hello-pangea/dnd";
const { Text } = Typography; const { Text } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
@@ -46,6 +48,9 @@ export const InvoiceDraftPage: React.FC = () => {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set()); 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); const [previewVisible, setPreviewVisible] = useState(false);
@@ -68,6 +73,7 @@ export const InvoiceDraftPage: React.FC = () => {
queryFn: () => api.getDraft(id!), queryFn: () => api.getDraft(id!),
enabled: !!id, enabled: !!id,
refetchInterval: (query) => { refetchInterval: (query) => {
if (isDragging) return false;
const status = query.state.data?.status; const status = query.state.data?.status;
return status === "PROCESSING" ? 3000 : false; 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(() => { useEffect(() => {
if (draft) { if (draft) {
const currentValues = form.getFieldsValue(); 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 --- // --- RENDER ---
const showSpinner = const showSpinner =
draftQuery.isLoading || draftQuery.isLoading ||
@@ -444,19 +526,65 @@ export const InvoiceDraftPage: React.FC = () => {
</div> </div>
{/* Items List */} {/* Items List */}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}> {enabled ? (
{draft.items.map((item) => ( <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 <DraftItemRow
key={item.id} key={item.id}
item={item} 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} onUpdate={handleItemUpdate}
// Передаем обработчик удаления
onDelete={(itemId) => deleteItemMutation.mutate(itemId)} onDelete={(itemId) => deleteItemMutation.mutate(itemId)}
isUpdating={updatingItems.has(item.id)} isUpdating={updatingItems.has(item.id)}
recommendations={recommendationsQuery.data || []} recommendations={recommendationsQuery.data || []}
/> />
))} ))}
</div> </div>
)}
{/* Кнопка добавления позиции */} {/* Кнопка добавления позиции */}
<Button <Button

View File

@@ -18,6 +18,7 @@ import type {
DraftItem, DraftItem,
UpdateDraftItemRequest, UpdateDraftItemRequest,
CommitDraftRequest, CommitDraftRequest,
ReorderDraftItemsRequest,
ProductSearchResult, ProductSearchResult,
AddContainerRequest, AddContainerRequest,
AddContainerResponse, AddContainerResponse,
@@ -208,6 +209,10 @@ export const api = {
await apiClient.delete(`/drafts/${draftId}/items/${itemId}`); 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 }> => { commitDraft: async (draftId: string, payload: CommitDraftRequest): Promise<{ document_number: string }> => {
const { data } = await apiClient.post<{ document_number: string }>(`/drafts/${draftId}/commit`, payload); const { data } = await apiClient.post<{ document_number: string }>(`/drafts/${draftId}/commit`, payload);
return data; return data;

View File

@@ -237,6 +237,14 @@ export interface CommitDraftRequest {
comment: string; comment: string;
incoming_document_number?: string; incoming_document_number?: string;
} }
export interface ReorderDraftItemsRequest {
items: Array<{
id: UUID;
order: number;
}>;
}
export interface MainUnit { export interface MainUnit {
id: UUID; id: UUID;
name: string; // "кг" name: string; // "кг"